diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index 70596aa88..5f33222ea 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -10,8 +10,8 @@ on: - '.gitignore' - 'database/**' - 'documentation/**' - - 'openshift/**' - - 'tests/**' + - '**/docs/**' + - '**/README*' - 'CODE_OF_CONDUCT.md' - 'COMPLIANCE.yaml' - 'CONTRIBUTING.md' diff --git a/.github/workflows/docker-build-main.yml b/.github/workflows/docker-build-main.yml index f583143dc..c0294fe06 100644 --- a/.github/workflows/docker-build-main.yml +++ b/.github/workflows/docker-build-main.yml @@ -10,8 +10,8 @@ on: - '.gitignore' - 'database/**' - 'documentation/**' - - 'openshift/**' - - 'tests/**' + - '**/docs/**' + - '**/README*' - 'CODE_OF_CONDUCT.md' - 'COMPLIANCE.yaml' - 'CONTRIBUTING.md' diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index c061a34c6..3b7e9d91f 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -10,8 +10,8 @@ on: - '.gitignore' - 'database/**' - 'documentation/**' - - 'openshift/**' - - 'tests/**' + - '**/docs/**' + - '**/README*' - 'CODE_OF_CONDUCT.md' - 'COMPLIANCE.yaml' - 'CONTRIBUTING.md' diff --git a/.github/workflows/pr-check-dev-branch.yml b/.github/workflows/pr-check-dev-branch.yml index f08a1749d..aaadd9726 100644 --- a/.github/workflows/pr-check-dev-branch.yml +++ b/.github/workflows/pr-check-dev-branch.yml @@ -1,4 +1,4 @@ -name: Dev - CI & Unit Tests +name: Dev - Branch Protection - CI & Unit Tests permissions: contents: read diff --git a/.github/workflows/pr-check-main-branch.yml b/.github/workflows/pr-check-main-branch.yml index 8199f1aba..205a2c9d8 100644 --- a/.github/workflows/pr-check-main-branch.yml +++ b/.github/workflows/pr-check-main-branch.yml @@ -1,4 +1,4 @@ -name: Main - Branch Protection +name: Main - Branch Protection - CI & Unit Tests permissions: contents: read pull-requests: write diff --git a/.gitignore b/.gitignore index 841973049..cf56f6bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ *.rsuser *.userosscache *.sln.docstates +*.code-workspace # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/README.md b/README.md index 4425d1952..46240d8e9 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,8 @@ The project is in a reliable state and major changes are unlikely to happen. ├── Unity.NginxData/ - Nginx HTTP server and reference files ├── Unity.RabbitMQ/ - RabbitMQ message broker configuration └── Unity.RedisSentinel/- Redis Sentinel high-availability setup - database/ - Database configuration and scripts - documentation/ - Solution documentation and assets - openshift/ - OpenShift deployment files and configs + database/ - Database configuration scripts + documentation/ - Solution documentation COMPLIANCE.yaml - BCGov PIA/STRA compliance status CONTRIBUTING.md - How to contribute LICENSE - License diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts index 8081e917c..7bbdde704 100644 --- a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts @@ -1,7 +1,7 @@ /// import { loginIfNeeded } from "../support/auth"; -import { ApplicationsListPage } from "../pages/ApplicationDetailsPage"; +import { ApplicationsListPage } from "../pages/ApplicationsListPage"; describe("Unity Login and check data from CHEFS", () => { const page = new ApplicationsListPage(); @@ -171,13 +171,12 @@ describe("Unity Login and check data from CHEFS", () => { 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 + it("Tests the existence and functionality of the Quick Date Range filter", () => { + // Select "All time" from quick date range dropdown and verify table refreshes page - .setSubmittedFromDate("2022-01-01") + .selectQuickDateRange("alltime") .waitForTableRefresh() - .setSubmittedToDate(page.getTodayIsoLocal()) - .waitForTableRefresh(); + .verifyQuickDateRangeValue("alltime"); }); // With no rows selected verify the visibility of Filter, Export, Save View, and Columns. @@ -196,7 +195,7 @@ describe("Unity Login and check data from CHEFS", () => { .verifyTableHasData() .selectMultipleRows([0, 1]) .verifyActionBarExists() - .clickPaymentButton() + .clickPaymentButtonWithWait() .waitForPaymentModalVisible() .closePaymentModal() .verifyPaymentModalClosed(); diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 152081bae..0c08e11ce 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -269,6 +269,24 @@ describe("Send an email", () => { }); it("Open Emails tab", () => { + // Dismiss any swal2 modal that may be covering the tab + cy.get("body").then(($body) => { + if ($body.find(".swal2-container").length > 0) { + cy.get(".swal2-container").then(($swal) => { + if ($swal.find(".swal2-close").length > 0) { + cy.get(".swal2-close").click({ force: true }); + } else if ($swal.find(".swal2-confirm").length > 0) { + cy.get(".swal2-confirm").click({ force: true }); + } else { + cy.get("body").type("{esc}", { force: true }); + } + }); + cy.get(".swal2-container", { timeout: STANDARD_TIMEOUT }).should( + "not.exist", + ); + } + }); + cy.get("#emails-tab", { timeout: STANDARD_TIMEOUT }) .should("exist") .should("be.visible") diff --git a/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts b/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts index b9ec8948c..05fe3e5a5 100644 --- a/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts @@ -59,13 +59,12 @@ describe('Unity Login and check data from CHEFS', () => { // Ensure the search field exists cy.get('#search', { timeout: STANDARD_TIMEOUT }).should('exist') - // Conditionally widen Submitted Date range if the control exists + // Select "All time" from quick date range to widen the search cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => { - if ($body.find('input#submittedFromDate').length > 0) { - cy.get('input#submittedFromDate', { timeout: STANDARD_TIMEOUT }) - .should('exist') - .clear() - .type('2022-01-01') + if ($body.find('select#quickDateRange').length > 0) { + cy.get('select#quickDateRange', { timeout: STANDARD_TIMEOUT }) + .should('be.visible') + .select('alltime') } }) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 34eb15827..6194b02f1 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -2,506 +2,6 @@ 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 diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts new file mode 100644 index 000000000..aae8eb72b --- /dev/null +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -0,0 +1,574 @@ +/// + +import { ApplicationsPage } from "./ListPages"; + +/** + * ApplicationsListPage - Extended Page Object for the Grant Applications List page + * Extends ApplicationsPage with additional functionality for: + * - Date filters + * - Columns menu operations + * - Payment modal handling + * - Table horizontal scrolling and column visibility + */ +export class ApplicationsListPage extends ApplicationsPage { + private readonly STANDARD_TIMEOUT = 20000; + private readonly BUTTON_TIMEOUT = 60000; + + // Date filter selectors + private readonly dateFilters = { + quickDateRange: "select#quickDateRange", + submittedFromDate: "input#submittedFromDate", + submittedToDate: "input#submittedToDate", + spinner: 'div.spinner-grow[role="status"]', + }; + + // Quick date range option values + private readonly quickDateRangeOptions = { + today: "today", + last7Days: "last7days", + last30Days: "last30days", + last3Months: "last3months", + last6Months: "last6months", + allTime: "alltime", + custom: "custom", + }; + + // Extended action bar selectors (beyond ApplicationsPage) + private readonly extendedActionBar = { + customButtons: "#app_custom_buttons", + dynamicButtonContainer: "#dynamicButtonContainerId", + exportButton: "#dynamicButtonContainerId .dt-buttons button span", + saveViewButton: "button.grp-savedStates", + }; + + // Table scrolling selectors + private readonly scrollTable = { + 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", + programsTableRow: "#UserGrantProgramsTable tbody tr", + }; + + // Save view selectors + private readonly saveView = { + button: "button.grp-savedStates", + resetOption: "a.dropdown-item", + }; + + constructor() { + super(); + } + + // ============ Date Filter Methods ============ + + /** + * Select a quick date range from the dropdown + * @param range - One of: "today", "last7days", "last30days", "last3months", "last6months", "alltime", "custom" + */ + selectQuickDateRange( + range: + | "today" + | "last7days" + | "last30days" + | "last3months" + | "last6months" + | "alltime" + | "custom" + ): this { + cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .select(range); + return this; + } + + /** + * Select "Last 6 months" from quick date range (default) + */ + selectLast6Months(): this { + return this.selectQuickDateRange("last6months"); + } + + /** + * Select "All time" from quick date range + */ + selectAllTime(): this { + return this.selectQuickDateRange("alltime"); + } + + /** + * Verify the quick date range dropdown has expected value + */ + verifyQuickDateRangeValue( + expectedValue: + | "today" + | "last7days" + | "last30days" + | "last3months" + | "last6months" + | "alltime" + | "custom" + ): this { + cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Set the Submitted From Date filter (for custom date range) + * @deprecated Use selectQuickDateRange() instead. This method is for custom date ranges only. + */ + setSubmittedFromDate(date: string): this { + cy.get(this.dateFilters.submittedFromDate, { timeout: this.STANDARD_TIMEOUT }) + .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())}`; + } + + // ============ Extended Table Methods ============ + + /** + * Verify table has rows (using scroll body selector) + */ + verifyTableHasData(): this { + cy.get(this.scrollTable.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.scrollTable.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.scrollTable.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.scrollTable.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 (case-insensitive) + */ + assertVisibleHeadersInclude(expected: string[]): this { + this.getVisibleHeaderTitles().then((titles: string[]) => { + const titlesLower = titles.map((t: string) => t.toLowerCase()); + expected.forEach((e: string) => { + expect( + titlesLower, + `visible headers should include "${e}"` + ).to.include(e.toLowerCase()); + }); + }); + return this; + } + + // ============ Extended Action Bar Methods ============ + + /** + * Scroll to and verify action bar exists + */ + verifyActionBarExists(): this { + cy.get(this.extendedActionBar.customButtons, { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .scrollIntoView(); + return this; + } + + /** + * Click the Payment button (extended with visibility checks) + */ + clickPaymentButtonWithWait(): this { + cy.get("#applicationPaymentRequest", { 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.extendedActionBar.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.extendedActionBar.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 (check existence first to avoid timeout) + cy.get("body").then(($body: JQuery) => { + if ($body.find(this.paymentModal.backdrop).length > 0) { + cy.get(this.paymentModal.backdrop).click("topLeft", { force: true }); + } + }); + + // Try Cancel button if available (check existence first to avoid timeout) + cy.get("body").then(($body: JQuery) => { + const $cancelBtn = $body.find(this.paymentModal.cancelButton).filter( + (_: number, el: HTMLElement) => (el.textContent || "").includes("Cancel") + ); + if ($cancelBtn.length > 0) { + cy.wrap($cancelBtn.first()).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.scrollTable.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 (case-insensitive) + */ + clickColumnsItem(label: string): this { + cy.contains(this.columnsMenu.dropdownItem, label, { + timeout: this.STANDARD_TIMEOUT, + matchCase: false, + }) + .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 + * Note: Consider using NavigationPage.switchToTenantIfAvailable() for consistency + */ + 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; + } +} diff --git a/applications/Unity.AutoUI/cypress/support/auth.ts b/applications/Unity.AutoUI/cypress/support/auth.ts index 764222b8f..bd83e88c5 100644 --- a/applications/Unity.AutoUI/cypress/support/auth.ts +++ b/applications/Unity.AutoUI/cypress/support/auth.ts @@ -176,31 +176,31 @@ function performLogin(options: LoginOptions = {}): void { */ export function loginIfNeeded( options: LoginOptions = {}, -): Cypress.Chainable { +): void { const username = options.username || (Cypress.env("test1username") as string); const baseUrl = options.baseUrl || (Cypress.env("webapp.url") as string); const sessionId = `unity-${baseUrl}-${username}`; - return cy - .session( - sessionId, - () => { - performLogin(options); - }, - { - validate() { - // Lightweight validation: check auth cookies exist without visiting - return cy.getCookie(".AspNetCore.Cookies").then((cookie) => { - if (!cookie) { - throw new Error("Session expired - auth cookie missing"); - } - }); - }, - cacheAcrossSpecs: true, + cy.session( + sessionId, + () => { + performLogin(options); + }, + { + validate() { + // Lightweight validation: check auth cookies exist without visiting + cy.getCookie(".AspNetCore.Cookies").then((cookie) => { + if (!cookie) { + throw new Error("Session expired - auth cookie missing"); + } + }); }, - ) - .then(() => { - cy.visit(baseUrl); - ensureGrantApplicationsPage(options.timeout || 20000); - }); + cacheAcrossSpecs: true, + }, + ); + + cy.then(() => { + cy.visit(baseUrl); + ensureGrantApplicationsPage(options.timeout || 20000); + }); } diff --git a/applications/Unity.AutoUI/cypress/support/e2e.ts b/applications/Unity.AutoUI/cypress/support/e2e.ts index 29ee2545b..860eac1b2 100644 --- a/applications/Unity.AutoUI/cypress/support/e2e.ts +++ b/applications/Unity.AutoUI/cypress/support/e2e.ts @@ -6,4 +6,14 @@ // https://on.cypress.io/configuration // *********************************************************** -import '../support/commands.ts' +import '../support/commands' + +// Ignore ResizeObserver loop errors - these are benign browser notifications +// that occur when ResizeObserver callbacks don't complete in a single animation frame +Cypress.on('uncaught:exception', (err) => { + if (err.message.includes('ResizeObserver loop')) { + return false + } + // Return true to fail the test for other errors + return true +}) diff --git a/applications/Unity.AutoUI/tsconfig.json b/applications/Unity.AutoUI/tsconfig.json index 6e47c796f..89a0c8e41 100644 --- a/applications/Unity.AutoUI/tsconfig.json +++ b/applications/Unity.AutoUI/tsconfig.json @@ -106,5 +106,5 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, // Included or excluded files or folders... https://www.typescriptlang.org/tsconfig - "include": ["cypress/support/**/*.ts","cypress/e2e/**/*.ts"] + "include": ["cypress/support/**/*.ts", "cypress/e2e/**/*.ts", "cypress/pages/**/*.ts"] } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs index 3ae0ec372..2f6a2b4c0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs @@ -1,22 +1,23 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.CheckboxGroupDefinitionWidget; using Unity.Flex.Worksheets.Definitions; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class CheckboxGroupDefinitionWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class CheckboxGroupDefinitionWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public CheckboxGroupDefinitionWidgetTests() + public CheckboxGroupDefinitionWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs index 2dad6b56e..ce6dc3d95 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.CheckboxGroupWidget; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class CheckboxGroupWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class CheckboxGroupWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public CheckboxGroupWidgetTests() + public CheckboxGroupWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs index d50ed7d57..6a9353f98 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.CheckboxWidget; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class CheckboxWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class CheckboxWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public CheckboxWidgetTests() + public CheckboxWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/ComponentTestFixture.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/ComponentTestFixture.cs new file mode 100644 index 000000000..731cb41c9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/ComponentTestFixture.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Unity.GrantManager; +using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; + +namespace Unity.Flex.Web.Tests.Components; + +public class ComponentTestFixture : WebApplicationFactory +{ + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var hostedServices = services + .Where(d => typeof(IHostedService) + .IsAssignableFrom(d.ServiceType)) + .ToList(); + + foreach (var descriptor in hostedServices) + { + if (descriptor.ImplementationType?.Namespace?.Contains("RabbitMQ") == true) + { + services.Remove(descriptor); + } + } + +if (OperatingSystem.IsWindows()) + { + services.RemoveAll(); + } + + services.Replace( + ServiceDescriptor.Singleton() + ); + }); + } +} + +[CollectionDefinition(ComponentTestCollection.Name)] +public class ComponentTestCollection : ICollectionFixture +{ + public const string Name = "ComponentTests"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs index 8815b4730..54a780f2f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs @@ -1,23 +1,23 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.CurrencyDefinitionWidget; using Unity.Flex.Worksheets.Definitions; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class CurrencyDefinitionWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class CurrencyDefinitionWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public CurrencyDefinitionWidgetTests() + public CurrencyDefinitionWidgetTests(ComponentTestFixture fixture) { - // Lazily resolve services needed by the ViewComponent - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs index d566f5dc2..8389402f3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.CurrencyWidget; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class CurrencyWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class CurrencyWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public CurrencyWidgetTests() + public CurrencyWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs index 62ec7d722..35e7663b1 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs @@ -1,22 +1,23 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.DataGridDefinitionWidget; using Unity.Flex.Worksheets.Definitions; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class DataGridDefinitionWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class DataGridDefinitionWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public DataGridDefinitionWidgetTests() + public DataGridDefinitionWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs index 6e18ab44d..0fab52c5d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.DataGridWidget; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class DataGridWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class DataGridWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public DataGridWidgetTests() + public DataGridWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs index 67c69a018..c79d83181 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.RadioWidget; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class RadioWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class RadioWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public RadioWidgetTests() + public RadioWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs index a63c2c36e..95ef22e1d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs @@ -1,22 +1,23 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.SelectListDefinitionWidget; using Unity.Flex.Worksheets.Definitions; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class SelectListDefinitionWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class SelectListDefinitionWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public SelectListDefinitionWidgetTests() + public SelectListDefinitionWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs index 167b2d182..245fb1e37 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.SelectListWidget; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class SelectListWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class SelectListWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public SelectListWidgetTests() + public SelectListWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs index 029d54586..66d091906 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs @@ -1,22 +1,23 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.TextAreaDefinitionWidget; using Unity.Flex.Worksheets.Definitions; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class TextAreaDefinitionWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class TextAreaDefinitionWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public TextAreaDefinitionWidgetTests() + public TextAreaDefinitionWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs index f8cbf63d7..5e1f09518 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.TextAreaWidget; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class TextAreaWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class TextAreaWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public TextAreaWidgetTests() + public TextAreaWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs index 065af83a5..ea5de7302 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs @@ -1,22 +1,23 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.TextDefinitionWidget; using Unity.Flex.Worksheets.Definitions; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class TextDefinitionWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class TextDefinitionWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public TextDefinitionWidgetTests() + public TextDefinitionWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs index 145cb3ea9..c56a19980 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs @@ -1,21 +1,22 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; using Unity.Flex.Web.Views.Shared.Components.YesNoWidget; -using Unity.GrantManager; using Volo.Abp.DependencyInjection; namespace Unity.Flex.Web.Tests.Components { - public class YesNoWidgetTests : GrantManagerWebTestBase + [Collection(ComponentTestCollection.Name)] + public class YesNoWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public YesNoWidgetTests() + public YesNoWidgetTests(ComponentTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js index fbb5a29cb..ca8c59ccc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js @@ -285,7 +285,10 @@ selector: `#${editorId}`, plugins: 'lists link image preview code', toolbar: 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview | variablesDropdownButton', - statusbar: false, + resize: true, + statusbar: true, + elementpath: false, + branding: false, promotion: false, content_css: false, skin: false, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs new file mode 100644 index 000000000..71053bd12 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs @@ -0,0 +1,11 @@ +using System; + +namespace Unity.Payments.PaymentRequests; + +[Serializable] +public class ApplicationPaymentRollupDto +{ + public Guid ApplicationId { get; set; } + public decimal TotalPaid { get; set; } + public decimal TotalPending { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs index be35d094c..7d1295eee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs @@ -21,5 +21,7 @@ public interface IPaymentRequestAppService : IApplicationService Task GetUserPaymentThresholdAsync(); Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds); Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); + Task GetApplicationPaymentRollupAsync(Guid applicationId); + Task> GetApplicationPaymentRollupBatchAsync(List applicationIds); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs index e00b7be5a..b6ef9d46d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs @@ -4,12 +4,13 @@ using Unity.Payments.PaymentTags; using Unity.Payments.Suppliers; using Volo.Abp.Application.Dtos; +using Volo.Abp.MultiTenancy; namespace Unity.Payments.PaymentRequests { #pragma warning disable CS8618 [Serializable] - public class PaymentRequestDto : AuditedEntityDto + public class PaymentRequestDto : AuditedEntityDto, IMultiTenant { public string InvoiceNumber { get; set; } public decimal Amount { get; set; } @@ -46,6 +47,8 @@ public class PaymentRequestDto : AuditedEntityDto public DateTime? FsbNotificationSentDate { get; set; } public string? FsbApNotified { get; set; } + public Guid? TenantId { get; set; } + public static explicit operator PaymentRequestDto(CreatePaymentRequestDto v) { throw new NotImplementedException(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs index 5ae55debb..e4d488fcf 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Unity.Payments.PaymentRequests; using Volo.Abp.Domain.Repositories; namespace Unity.Payments.Domain.PaymentRequests @@ -14,6 +15,6 @@ public interface IPaymentRequestRepository : IRepository Task GetPaymentRequestByInvoiceNumber(string invoiceNumber); Task> GetPaymentRequestsByFailedsStatusAsync(); Task> GetPaymentPendingListByCorrelationIdAsync(Guid correlationId); - + Task> GetBatchPaymentRollupsByCorrelationIdsAsync(List correlationIds); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs index 222dc9f83..3578156da 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs @@ -35,5 +35,9 @@ public interface IPaymentRequestQueryManager // Pending Payments Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId); + + // Payment Rollups (paid + pending aggregation) + Task GetApplicationPaymentRollupAsync(Guid applicationId, List childApplicationIds); + Task> GetApplicationPaymentRollupBatchAsync(List applicationIds, Dictionary> childApplicationIdsByParent); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs index f5fe8e972..41aa2b516 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -88,8 +88,10 @@ public async Task CreatePaymentRequestDtoAsync(Guid paymentRe public async Task> GetListByApplicationIdsAsync(List applicationIds) { var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); - var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); - var filteredPayments = payments.Where(pr => applicationIds.Contains(pr.CorrelationId)).ToList(); + var filteredPayments = await paymentsQueryable + .Include(pr => pr.Site) + .Where(pr => applicationIds.Contains(pr.CorrelationId)) + .ToListAsync(); return objectMapper.Map, List>(filteredPayments); } @@ -97,8 +99,10 @@ public async Task> GetListByApplicationIdsAsync(List> GetListByApplicationIdAsync(Guid applicationId) { var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); - var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync(); - var filteredPayments = payments.Where(e => e.CorrelationId == applicationId).ToList(); + var filteredPayments = await paymentsQueryable + .Include(pr => pr.Site) + .Where(e => e.CorrelationId == applicationId) + .ToListAsync(); return objectMapper.Map, List>(filteredPayments); } @@ -218,5 +222,98 @@ public async Task> GetPaymentPendingListByCorrelationIdA var payments = await paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(applicationId); return objectMapper.Map, List>(payments); } + + /// + /// Retrieves a payment rollup for the specified application and its associated child applications. + /// + /// This method combines payment information from both the main application and its child + /// applications, providing an overall view of payment status. Use this method to obtain a single rollup when + /// displaying applications with related child records. + /// The unique identifier of the main application for which the payment rollup is requested. + /// A list of unique identifiers representing child applications whose payment data will be included in the + /// rollup. Cannot be null. + /// An instance of containing the aggregated total paid and pending + /// amounts for the main application and its child applications. + public async Task GetApplicationPaymentRollupAsync(Guid applicationId, List childApplicationIds) + { + var allCorrelationIds = new List { applicationId }; + allCorrelationIds.AddRange(childApplicationIds); + + var batchRollup = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds); + + return new ApplicationPaymentRollupDto + { + ApplicationId = applicationId, + TotalPaid = batchRollup.Sum(s => s.TotalPaid), + TotalPending = batchRollup.Sum(s => s.TotalPending) + }; + } + + /// + /// Retrieves batch payment rollup information for the specified application IDs, aggregating totals + /// for both paid and pending amounts from parent and child applications. + /// + /// This method performs a single database query to efficiently aggregate payment data + /// for all specified parent and child applications. The results include all relevant payment information + /// for each application ID provided. + /// A list of unique identifiers for the applications whose payment rollups are to be retrieved. Cannot be + /// null or empty. + /// A dictionary that maps each parent application ID to a list of its child application IDs. Must not be null + /// and should contain valid GUIDs. + /// A dictionary where each key is an application ID and the value is an containing + /// the total paid and pending amounts for that application, including amounts from any child applications. + public async Task> GetApplicationPaymentRollupBatchAsync( + List applicationIds, + Dictionary> childApplicationIdsByParent) + { + // Collect all unique correlation IDs (parents + all children) for a single DB query + var allCorrelationIds = new HashSet(applicationIds); + foreach (var childIds in childApplicationIdsByParent.Values) + { + foreach (var childId in childIds) + { + allCorrelationIds.Add(childId); + } + } + + var paymentRollups = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds.ToList()); + var rollupLookup = paymentRollups.ToDictionary(s => s.ApplicationId); + + var result = new Dictionary(); + foreach (var applicationId in applicationIds) + { + decimal totalPaid = 0; + decimal totalPending = 0; + + // Add the parent application's own amounts + if (rollupLookup.TryGetValue(applicationId, out var parentRollup)) + { + totalPaid += parentRollup.TotalPaid; + totalPending += parentRollup.TotalPending; + } + + // Add child application amounts + if (childApplicationIdsByParent.TryGetValue(applicationId, out var childIds)) + { + foreach (var childId in childIds) + { + if (rollupLookup.TryGetValue(childId, out var childApplicationRollup)) + { + totalPaid += childApplicationRollup.TotalPaid; + totalPending += childApplicationRollup.TotalPending; + } + } + } + + result[applicationId] = new ApplicationPaymentRollupDto + { + ApplicationId = applicationId, + TotalPaid = totalPaid, + TotalPending = totalPending + }; + } + + return result; + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index acb7a4a8b..95ecbb5c3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -1,4 +1,5 @@ -using System; +using Microsoft.EntityFrameworkCore; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -6,6 +7,7 @@ using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.EntityFrameworkCore; using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; using Volo.Abp.Domain.Repositories.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore; @@ -16,9 +18,6 @@ public class PaymentRequestRepository : EfCoreRepository ReCheckStatusList { get; set; } = new List(); private List FailedStatusList { get; set; } = new List(); - - - public PaymentRequestRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider) { ReCheckStatusList.Add(CasPaymentRequestStatus.ServiceUnavailable); @@ -32,25 +31,27 @@ public PaymentRequestRepository(IDbContextProvider dbContextP public async Task GetCountByCorrelationId(Guid correlationId) { var dbSet = await GetDbSetAsync(); - return dbSet.Count(s => s.CorrelationId == correlationId); + return await dbSet.CountAsync(s => s.CorrelationId == correlationId); } public async Task GetPaymentRequestCountBySiteId(Guid siteId) { var dbSet = await GetDbSetAsync(); - return dbSet.Where(s => s.SiteId == siteId).Count(); - } + return await dbSet.Where(s => s.SiteId == siteId) + .CountAsync(); + } public async Task GetPaymentRequestByInvoiceNumber(string invoiceNumber) { var dbSet = await GetDbSetAsync(); - return dbSet.Where(s => s.InvoiceNumber == invoiceNumber).FirstOrDefault(); + return await dbSet.Where(s => s.InvoiceNumber == invoiceNumber) + .FirstOrDefaultAsync(); } public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId) { var dbSet = await GetDbSetAsync(); - decimal applicationPaymentRequestsTotal = dbSet + decimal applicationPaymentRequestsTotal = await dbSet .Where(p => p.CorrelationId.Equals(correlationId)) .Where(p => p.Status != PaymentRequestStatus.L1Declined && p.Status != PaymentRequestStatus.L2Declined @@ -59,7 +60,7 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid && p.InvoiceStatus != CasPaymentRequestStatus.ErrorFromCas) .GroupBy(p => p.CorrelationId) .Select(p => p.Sum(q => q.Amount)) - .FirstOrDefault(); + .FirstOrDefaultAsync(); return applicationPaymentRequestsTotal; } @@ -67,15 +68,17 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid public async Task> GetPaymentRequestsBySentToCasStatusAsync() { var dbSet = await GetDbSetAsync(); - return dbSet.Where(p => p.InvoiceStatus != null && ReCheckStatusList.Contains(p.InvoiceStatus)).IncludeDetails().ToList(); + return await dbSet.Where(p => p.InvoiceStatus != null && ReCheckStatusList.Contains(p.InvoiceStatus)) + .IncludeDetails() + .ToListAsync(); } public async Task> GetPaymentRequestsByFailedsStatusAsync() { var dbSet = await GetDbSetAsync(); - return dbSet.Where(p => p.InvoiceStatus != null - && FailedStatusList.Contains(p.InvoiceStatus) - && p.LastModificationTime >= DateTime.Now.AddDays(-2)).IncludeDetails().ToList(); + return await dbSet.Where(p => p.InvoiceStatus != null + && FailedStatusList.Contains(p.InvoiceStatus) + && p.LastModificationTime >= DateTime.Now.AddDays(-2)).IncludeDetails().ToListAsync(); } public override async Task> WithDetailsAsync() @@ -87,8 +90,52 @@ public override async Task> WithDetailsAsync() public async Task> GetPaymentPendingListByCorrelationIdAsync(Guid correlationId) { var dbSet = await GetDbSetAsync(); - return dbSet.Where(p => p.CorrelationId.Equals(correlationId)) - .Where(p => p.Status == PaymentRequestStatus.L1Pending || p.Status == PaymentRequestStatus.L2Pending).IncludeDetails().ToList(); + return await dbSet.Where(p => p.CorrelationId.Equals(correlationId)) + .Where(p => p.Status == PaymentRequestStatus.L1Pending || p.Status == PaymentRequestStatus.L2Pending) + .IncludeDetails() + .ToListAsync(); + } + + /// + /// Asynchronously retrieves payment rollup information for each specified correlation ID. + /// + /// This method queries the database for payment records associated with the provided + /// correlation IDs and aggregates payment amounts based on their status. Ensure that the correlation IDs are + /// valid to avoid empty results. + /// A list of correlation IDs used to filter payment records. Each ID must be a valid GUID. + /// A task that represents the asynchronous operation. The task result contains a list of + /// ApplicationPaymentRollupDto objects, each summarizing the total paid and total pending amounts for the + /// corresponding correlation ID. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", + "CA1862:Use the 'StringComparison' method overloads to perform case-insensitive string comparisons", + Justification = "EF Core does not support StringComparison - https://github.com/dotnet/efcore/issues/1222")] + public async Task> GetBatchPaymentRollupsByCorrelationIdsAsync(List correlationIds) + { + var dbSet = await GetDbSetAsync(); + + var results = await dbSet + .Where(p => correlationIds.Contains(p.CorrelationId)) + .GroupBy(p => p.CorrelationId) + .Select(g => new ApplicationPaymentRollupDto + { + ApplicationId = g.Key, + TotalPaid = g + .Where(p => p.PaymentStatus != null + && p.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper()) + .Sum(p => p.Amount), + TotalPending = g + .Where(p => p.Status == PaymentRequestStatus.L1Pending + || p.Status == PaymentRequestStatus.L2Pending + || p.Status == PaymentRequestStatus.L3Pending + || (p.Status == PaymentRequestStatus.Submitted + && string.IsNullOrEmpty(p.PaymentStatus) + && (string.IsNullOrEmpty(p.InvoiceStatus) + || !p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas)))) + .Sum(p => p.Amount) + }) + .ToListAsync(); + + return results; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs index 225fb33fd..6f0a838ea 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs @@ -11,6 +11,7 @@ using Unity.GrantManager.Integrations.Css; using Volo.Abp.DependencyInjection; using Volo.Abp.TenantManagement; +using Microsoft.Extensions.Logging; namespace Unity.Payments.Integrations.Cas { [RemoteService(false)] @@ -35,11 +36,11 @@ public async Task GetAuthTokenAsync(Guid tenantId) var tenant = await tenantRepository.GetAsync(tenantId); var casClientCode = tenant.ExtraProperties?["CasClientCode"]?.ToString(); - if (string.IsNullOrEmpty(casClientCode)) { throw new UserFriendlyException("No CAS client code configured for the current tenant. Please contact your administrator."); } + Logger.LogInformation("Retrieved CAS client code {CasClientCode} for tenant {TenantId}", casClientCode, tenantId); var casClientId = await casClientCodeLookupService.GetClientIdByCasClientCodeAsync(casClientCode) ?? throw new UserFriendlyException($"No CAS client configuration found for CAS client code: {casClientCode}"); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs index 148749505..57b08ccd9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs @@ -163,9 +163,10 @@ public async Task GetCasInvoiceAsync(string invoiceNumbe } } - public async Task GetCasPaymentAsync(string invoiceNumber, string supplierNumber, string siteNumber) + public async Task GetCasPaymentAsync(Guid tenantId, string invoiceNumber, string supplierNumber, string siteNumber) { - var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty); + Logger.LogInformation("GetCasPaymentAsync for Invoice: {InvoiceNumber}, SupplierNumber: {SupplierNumber}, SiteNumber: {SiteNumber}, TenantId: {TenantId}", invoiceNumber, supplierNumber, siteNumber, tenantId); + var authToken = await iTokenService.GetAuthTokenAsync(tenantId); var casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE); var resource = $"{casBaseUrl}/{CFS_APINVOICE}/{invoiceNumber}/{supplierNumber}/{siteNumber}"; var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs index 5ee1bed7b..c462ae9b3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs @@ -4,31 +4,36 @@ using System; using Unity.Payments.PaymentRequests; using Unity.Payments.Integrations.Cas; +using Volo.Abp.MultiTenancy; namespace Unity.Payments.Integrations.RabbitMQ; public class ReconciliationConsumer( CasPaymentRequestCoordinator casPaymentRequestCoordinator, - InvoiceService invoiceService + InvoiceService invoiceService, + ICurrentTenant currentTenant ) : IQueueConsumer { public async Task ConsumeAsync(ReconcilePaymentMessages reconcilePaymentMessage) { if (reconcilePaymentMessage != null && !reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() && reconcilePaymentMessage.TenantId != Guid.Empty) - { + { - // string invoiceNumber, string supplierNumber, string siteNumber) - // Go to CAS retrieve the status of the payment - CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( - reconcilePaymentMessage.InvoiceNumber, - reconcilePaymentMessage.SupplierNumber, - reconcilePaymentMessage.SiteNumber); - - if (result != null && result.InvoiceStatus != null && result.InvoiceStatus != "") + using (currentTenant.Change(reconcilePaymentMessage.TenantId)) { - await casPaymentRequestCoordinator.UpdatePaymentRequestStatus(reconcilePaymentMessage.TenantId, reconcilePaymentMessage.PaymentRequestId, result); - } + // string invoiceNumber, string supplierNumber, string siteNumber) + // Go to CAS retrieve the status of the payment + CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( + reconcilePaymentMessage.TenantId, + reconcilePaymentMessage.InvoiceNumber, + reconcilePaymentMessage.SupplierNumber, + reconcilePaymentMessage.SiteNumber); + if (result != null && result.InvoiceStatus != null && result.InvoiceStatus != "") + { + await casPaymentRequestCoordinator.UpdatePaymentRequestStatus(reconcilePaymentMessage.TenantId, reconcilePaymentMessage.PaymentRequestId, result); + } + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index e908d215f..84b8cc8fa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -92,7 +92,7 @@ public async Task ManuallyAddPaymentRequestsToReconciliationQueue(List applicationLinksService) : PaymentsAppService, IPaymentRequestAppService { public async Task GetDefaultAccountCodingId() @@ -328,5 +330,38 @@ public async Task> GetPaymentPendingListByCorrelationIdA { return await paymentRequestQueryManager.GetPaymentPendingListByCorrelationIdAsync(applicationId); } + + /// + /// Retrieves the payment rollup for the specified application, including any linked child + /// applications. + /// + /// This method first obtains any child applications associated with the given + /// application ID and then queries the payment rollup for both the application and its children. Use this + /// method to obtain a comprehensive payment overview when applications may be linked together. + /// The unique identifier of the application for which the payment rollup is requested. + /// An instance of containing the payment rollup details for the + /// specified application and its linked child applications. + public async Task GetApplicationPaymentRollupAsync(Guid applicationId) + { + var childLinks = await applicationLinksService.Value.GetChildApplications(applicationId); + var childApplicationIds = childLinks.Select(l => l.LinkedApplicationId).ToList(); + return await paymentRequestQueryManager.GetApplicationPaymentRollupAsync(applicationId, childApplicationIds); + } + + /// + /// Retrieves batch payment rollup information for the specified applications and their child applications. + /// + /// This method asynchronously resolves child applications linked to the provided + /// application identifiers before fetching a batch of payment rollups. Ensure that all application identifiers are valid + /// and exist in the system. + /// A list of application identifiers for which payment rollups are requested. This parameter cannot be null + /// or empty. + /// A dictionary mapping each application identifier to its corresponding . The dictionary + /// includes entries for both the specified applications and their child applications. + public async Task> GetApplicationPaymentRollupBatchAsync(List applicationIds) + { + var childApplicationIdsByParent = await applicationLinksService.Value.GetChildApplicationIdsByParentIdsAsync(applicationIds); + return await paymentRequestQueryManager.GetApplicationPaymentRollupBatchAsync(applicationIds, childApplicationIdsByParent); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index 5b7aff6ba..945406074 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -486,9 +486,11 @@ $(function () { title: l('ApplicationPaymentListTable:RequestedOn'), name: 'requestedOn', data: 'creationTime', - className: 'data-table-header', + className: 'data-table-header text-nowrap', index: columnIndex, - render: DataTable.render.date('YYYY-MM-DD', abp.localization.currentCulture.name) + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } }; } function getUpdatedOnColumn(columnIndex) { @@ -496,9 +498,11 @@ $(function () { title: l('ApplicationPaymentListTable:UpdatedOn'), name: 'updatedOn', data: 'lastModificationTime', - className: 'data-table-header', + className: 'data-table-header text-nowrap', index: columnIndex, - render: DataTable.render.date('YYYY-MM-DD', abp.localization.currentCulture.name) + render: function(data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } }; } function getPaidOnColumn(columnIndex) { @@ -506,7 +510,7 @@ $(function () { title: l('ApplicationPaymentListTable:PaidOn'), name: 'paidOn', data: 'paymentDate', - className: 'data-table-header', + className: 'data-table-header text-nowrap', index: columnIndex, render: function (data) { if (!data) return null; @@ -575,11 +579,12 @@ $(function () { title: l(`ApplicationPaymentListTable:L${level}ApprovalDate`), name: `l${level}ApprovalDate`, data: 'expenseApprovals', - className: 'data-table-header', + className: 'data-table-header text-nowrap', index: columnIndex, - render: function (data) { + render: function (data, type) { let approval = getExpenseApprovalsDetails(data, level); - return formatDate(approval?.decisionDate); + const approvalDate = approval?.decisionDate; + return DateUtils.formatUtcDateToLocal(approvalDate, type); } }; } @@ -711,12 +716,6 @@ $(function () { return expenseApprovals.find(x => x.type == type); } - function formatDate(data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : null; - } - $('#search').on('input', function () { let table = $('#PaymentRequestListTable').DataTable(); table.search($(this).val()).draw(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index 56cd6f0cb..999b15e07 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -336,8 +336,8 @@ data: 'creationTime', className: 'data-table-header', index: 5, - render: function (data) { - return formatDate(data); + render: function(data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, }; } @@ -349,8 +349,8 @@ data: 'lastModificationTime', className: 'data-table-header', index: 6, - render: function (data) { - return formatDate(data); + render: function(data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, }; } @@ -362,8 +362,8 @@ data: 'paidOn', className: 'data-table-header', index: 7, - render: function (data) { - return formatDate(data); + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); }, }; } @@ -470,16 +470,6 @@ }; } - function formatDate(data) { - return data != null - ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }) - .toUTC() - .toLocaleString() - : '{Not Available}'; - } - PubSub.subscribe('refresh_application_list', (msg, data) => { dataTable.ajax.reload(null, false); PubSub.publish('clear_payment_application'); 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..32ddf14fe 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 @@ -1,11 +1,8 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; @@ -24,17 +21,14 @@ public class PaymentInfoViewComponent : AbpViewComponent private readonly IGrantApplicationAppService _grantApplicationAppService; private readonly IPaymentRequestAppService _paymentRequestService; private readonly IFeatureChecker _featureChecker; - private readonly IApplicationLinksService _applicationLinksService; public PaymentInfoViewComponent(IGrantApplicationAppService grantApplicationAppService, IPaymentRequestAppService paymentRequestService, - IFeatureChecker featureChecker, - IApplicationLinksService applicationLinksService) + IFeatureChecker featureChecker) { _grantApplicationAppService = grantApplicationAppService; _paymentRequestService = paymentRequestService; _featureChecker = featureChecker; - _applicationLinksService = applicationLinksService; } public async Task InvokeAsync(Guid applicationId, Guid applicationFormVersionId) @@ -51,42 +45,10 @@ public async Task InvokeAsync(Guid applicationId, Guid app ApplicationFormVersionId = applicationFormVersionId, ApplicantId = application.Applicant.Id }; - - var paymentRequests = await _paymentRequestService.GetListByApplicationIdAsync(applicationId); - - // Calculate Total Paid and Total Pending Amounts for current application - var (paidAmount, pendingAmount) = CalculatePaymentAmounts(paymentRequests); - model.TotalPaid = paidAmount; - model.TotalPendingAmounts = pendingAmount; - - // Add Total Paid and Total Pending Amounts from child applications - var applicationLinks = await _applicationLinksService.GetListByApplicationAsync(applicationId); - var childApplications = applicationLinks - .Where(link => link.LinkType == ApplicationLinkType.Child - && link.ApplicationId != applicationId) // Exclude self-references - .ToList(); - - // Batch fetch payment requests for all child applications to avoid N+1 queries - var childApplicationIds = childApplications.Select(ca => ca.ApplicationId).ToList(); - if (childApplicationIds.Count != 0) - { - var childPaymentRequests = await _paymentRequestService.GetListByApplicationIdsAsync(childApplicationIds); - var paymentRequestsByAppId = childPaymentRequests - .GroupBy(pr => pr.CorrelationId) - .ToDictionary(g => g.Key, g => g.ToList()); - - foreach (var childApp in childApplications) - { - if (paymentRequestsByAppId.TryGetValue(childApp.ApplicationId, out var requests)) - { - // Add child's Total Paid and Total Pending Amounts - var (childPaidAmount, childPendingAmount) = CalculatePaymentAmounts(requests); - model.TotalPaid += childPaidAmount; - model.TotalPendingAmounts += childPendingAmount; - } - } - } + var rollup = await _paymentRequestService.GetApplicationPaymentRollupAsync(applicationId); + model.TotalPaid = rollup.TotalPaid; + model.TotalPendingAmounts = rollup.TotalPending; model.RemainingAmount = application.ApprovedAmount - model.TotalPaid; return View(model); @@ -95,27 +57,6 @@ public async Task InvokeAsync(Guid applicationId, Guid app return View(new PaymentInfoViewModel()); } - private static (decimal paidAmount, decimal pendingAmount) CalculatePaymentAmounts(List paymentRequests) - { - var requestsList = paymentRequests; - - var paidAmount = requestsList - .Where(e => !string.IsNullOrWhiteSpace(e.PaymentStatus) - && e.PaymentStatus.Trim().Equals("Fully Paid", StringComparison.OrdinalIgnoreCase)) - .Sum(e => e.Amount); - - var pendingAmount = requestsList - .Where(e => e.Status == PaymentRequestStatus.L1Pending - || e.Status == PaymentRequestStatus.L2Pending - || e.Status == PaymentRequestStatus.L3Pending - || (e.Status == PaymentRequestStatus.Submitted - && string.IsNullOrWhiteSpace(e.PaymentStatus) - && (string.IsNullOrWhiteSpace(e.InvoiceStatus) || !e.InvoiceStatus.Trim().Contains("Error", StringComparison.OrdinalIgnoreCase)))) - .Sum(e => e.Amount); - - return (paidAmount, pendingAmount); - } - public class PaymentInfoStyleBundleContributor : BundleContributor { public override void ConfigureBundle(BundleConfigurationContext context) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs new file mode 100644 index 000000000..49c2c4bd1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs @@ -0,0 +1,412 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Unity.Payments.Domain.Services; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Users; +using Xunit; + +namespace Unity.Payments.Domain.PaymentRequests; + +[Category("Domain")] +public class PaymentRequestQueryManager_PaymentRollup_Tests +{ + #region GetApplicationPaymentRollupAsync (Single Application) + + [Fact] + public async Task Should_Return_Rollup_For_Single_Application_With_NoChildren() + { + // Arrange + var appId = Guid.NewGuid(); + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = appId, TotalPaid = 1500m, TotalPending = 2000m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupAsync(appId, []); + + // Assert + result.ShouldNotBeNull(); + result.ApplicationId.ShouldBe(appId); + result.TotalPaid.ShouldBe(1500m); + result.TotalPending.ShouldBe(2000m); + + // Verify repo was called with only the parent ID + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync( + Arg.Is>(ids => ids.Count == 1 && ids.Contains(appId))); + } + + [Fact] + public async Task Should_Aggregate_Rollup_From_Parent_And_Children() + { + // Arrange + var parentId = Guid.NewGuid(); + var child1Id = Guid.NewGuid(); + var child2Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 500m }, + new() { ApplicationId = child1Id, TotalPaid = 300m, TotalPending = 200m }, + new() { ApplicationId = child2Id, TotalPaid = 700m, TotalPending = 100m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupAsync(parentId, [child1Id, child2Id]); + + // Assert + result.ShouldNotBeNull(); + result.ApplicationId.ShouldBe(parentId); + result.TotalPaid.ShouldBe(2000m); // 1000 + 300 + 700 + result.TotalPending.ShouldBe(800m); // 500 + 200 + 100 + + // Verify all IDs were sent to the repository + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync( + Arg.Is>(ids => ids.Count == 3 + && ids.Contains(parentId) && ids.Contains(child1Id) && ids.Contains(child2Id))); + } + + [Fact] + public async Task Should_Return_Zeros_When_No_Payment_Data_Exists() + { + // Arrange + var appId = Guid.NewGuid(); + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupAsync(appId, []); + + // Assert + result.ShouldNotBeNull(); + result.ApplicationId.ShouldBe(appId); + result.TotalPaid.ShouldBe(0m); + result.TotalPending.ShouldBe(0m); + } + + [Fact] + public async Task Should_Return_Zeros_When_Children_Have_No_Payments() + { + // Arrange + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentId, TotalPaid = 500m, TotalPending = 0m } + // childId has no payments - not returned by repository + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupAsync(parentId, [childId]); + + // Assert + result.TotalPaid.ShouldBe(500m); // Only parent amount + result.TotalPending.ShouldBe(0m); + } + + [Fact] + public async Task Should_Handle_Single_Child_Application() + { + // Arrange + var parentId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 0m }, + new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 300m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupAsync(parentId, [childId]); + + // Assert + result.ApplicationId.ShouldBe(parentId); + result.TotalPaid.ShouldBe(1500m); // 1000 + 500 + result.TotalPending.ShouldBe(300m); // 0 + 300 + } + + #endregion + + #region GetApplicationPaymentRollupsAsync (Batch) + + [Fact] + public async Task Should_Return_Batch_Rollups_For_Multiple_Applications_Without_Children() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var app3Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 200m }, + new() { ApplicationId = app2Id, TotalPaid = 500m, TotalPending = 100m }, + new() { ApplicationId = app3Id, TotalPaid = 0m, TotalPending = 3000m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupBatchAsync( + [app1Id, app2Id, app3Id], + new Dictionary>()); + + // Assert + result.Count.ShouldBe(3); + result[app1Id].TotalPaid.ShouldBe(1000m); + result[app1Id].TotalPending.ShouldBe(200m); + result[app2Id].TotalPaid.ShouldBe(500m); + result[app2Id].TotalPending.ShouldBe(100m); + result[app3Id].TotalPaid.ShouldBe(0m); + result[app3Id].TotalPending.ShouldBe(3000m); + } + + [Fact] + public async Task Should_Aggregate_Child_Amounts_In_Batch_Rollup() + { + // Arrange + var parentAId = Guid.NewGuid(); + var parentBId = Guid.NewGuid(); + var childA1Id = Guid.NewGuid(); + var childA2Id = Guid.NewGuid(); + var childB1Id = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { parentAId, [childA1Id, childA2Id] }, + { parentBId, [childB1Id] } + }; + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentAId, TotalPaid = 1000m, TotalPending = 100m }, + new() { ApplicationId = childA1Id, TotalPaid = 200m, TotalPending = 50m }, + new() { ApplicationId = childA2Id, TotalPaid = 300m, TotalPending = 75m }, + new() { ApplicationId = parentBId, TotalPaid = 500m, TotalPending = 0m }, + new() { ApplicationId = childB1Id, TotalPaid = 400m, TotalPending = 200m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupBatchAsync( + [parentAId, parentBId], childMap); + + // Assert + result.Count.ShouldBe(2); + + // Parent A: 1000+200+300 paid, 100+50+75 pending + result[parentAId].TotalPaid.ShouldBe(1500m); + result[parentAId].TotalPending.ShouldBe(225m); + result[parentAId].ApplicationId.ShouldBe(parentAId); + + // Parent B: 500+400 paid, 0+200 pending + result[parentBId].TotalPaid.ShouldBe(900m); + result[parentBId].TotalPending.ShouldBe(200m); + result[parentBId].ApplicationId.ShouldBe(parentBId); + } + + [Fact] + public async Task Should_Handle_Application_With_No_Matching_Rollup_In_Batch() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + // Only app1 has payment data, app2 doesn't + new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 500m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupBatchAsync( + [app1Id, app2Id], + new Dictionary>()); + + // Assert + result.Count.ShouldBe(2); + result[app1Id].TotalPaid.ShouldBe(1000m); + result[app1Id].TotalPending.ShouldBe(500m); + + // app2 gets zero amounts since no data was returned + result[app2Id].TotalPaid.ShouldBe(0m); + result[app2Id].TotalPending.ShouldBe(0m); + result[app2Id].ApplicationId.ShouldBe(app2Id); + } + + [Fact] + public async Task Should_Deduplicate_CorrelationIds_In_Batch_Repository_Call() + { + // Arrange - A child is shared between two parents (edge case) + var parentAId = Guid.NewGuid(); + var parentBId = Guid.NewGuid(); + var sharedChildId = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { parentAId, [sharedChildId] }, + { parentBId, [sharedChildId] } + }; + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = parentAId, TotalPaid = 100m, TotalPending = 0m }, + new() { ApplicationId = parentBId, TotalPaid = 200m, TotalPending = 0m }, + new() { ApplicationId = sharedChildId, TotalPaid = 50m, TotalPending = 25m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupBatchAsync( + [parentAId, parentBId], childMap); + + // Assert + // Verify repository was called with deduplicated IDs (3 unique, not 4) + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync( + Arg.Is>(ids => ids.Distinct().Count() == 3)); + + // Both parents should include the shared child's amounts + result[parentAId].TotalPaid.ShouldBe(150m); // 100 + 50 + result[parentAId].TotalPending.ShouldBe(25m); // 0 + 25 + result[parentBId].TotalPaid.ShouldBe(250m); // 200 + 50 + result[parentBId].TotalPending.ShouldBe(25m); // 0 + 25 + } + + [Fact] + public async Task Should_Make_Single_Repository_Call_For_Batch() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var child1Id = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { app1Id, [child1Id] } + }; + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + await manager.GetApplicationPaymentRollupBatchAsync([app1Id, app2Id], childMap); + + // Assert - should only call repository once (batch optimization) + await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()); + } + + [Fact] + public async Task Should_Return_Empty_Dictionary_For_Empty_Application_List() + { + // Arrange + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List()); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupBatchAsync( + [], new Dictionary>()); + + // Assert + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } + + [Fact] + public async Task Should_Handle_Parent_Without_Children_In_Mixed_Batch() + { + // Arrange - app1 has children, app2 does not + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + var childMap = new Dictionary> + { + { app1Id, [childId] } + // app2 has no entry in childMap + }; + + var repo = Substitute.For(); + repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>()) + .Returns(new List + { + new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 0m }, + new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 100m }, + new() { ApplicationId = app2Id, TotalPaid = 300m, TotalPending = 50m } + }); + + var manager = CreateManager(repo); + + // Act + var result = await manager.GetApplicationPaymentRollupBatchAsync( + [app1Id, app2Id], childMap); + + // Assert + result[app1Id].TotalPaid.ShouldBe(1500m); // 1000 + 500 + result[app1Id].TotalPending.ShouldBe(100m); // 0 + 100 + + result[app2Id].TotalPaid.ShouldBe(300m); // Only own amount + result[app2Id].TotalPending.ShouldBe(50m); // Only own amount + } + + #endregion + + #region Helpers + + private static PaymentRequestQueryManager CreateManager(IPaymentRequestRepository repo) + { + return new PaymentRequestQueryManager( + repo, + Substitute.For(), + Substitute.For(), + null!, // CasPaymentRequestCoordinator - not used by Rollup methods + null! // IObjectMapper - not used by Rollup methods + ); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs new file mode 100644 index 000000000..17a7da11f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs @@ -0,0 +1,411 @@ +using Shouldly; +using System; +using System.ComponentModel; +using System.Threading.Tasks; +using Unity.Modules.Shared.Correlation; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Uow; +using Xunit; + +namespace Unity.Payments.Domain.PaymentRequests; + +[Category("Integration")] +public class PaymentRequestRepository_PaymentRollup_Tests : PaymentsApplicationTestBase +{ + private readonly IPaymentRequestRepository _paymentRequestRepository; + private readonly ISupplierRepository _supplierRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public PaymentRequestRepository_PaymentRollup_Tests() + { + _paymentRequestRepository = GetRequiredService(); + _supplierRepository = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + #region PaymentStatus Case-Insensitive Matching + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_Exact_Case() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].ApplicationId.ShouldBe(correlationId); + results[0].TotalPaid.ShouldBe(1000m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_UpperCase() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(500m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_LowerCase() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 800m, + PaymentRequestStatus.Submitted, paymentStatus: "fully paid"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(800m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Count_FullyPaid_With_LeadingAndTrailingSpaces() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 300m, + PaymentRequestStatus.Submitted, paymentStatus: " Fully Paid "); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(300m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Aggregate_FullyPaid_Across_All_Case_Variations() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID"); + await InsertPaymentRequestAsync(siteId, correlationId, 800m, + PaymentRequestStatus.Submitted, paymentStatus: "fully paid"); + await InsertPaymentRequestAsync(siteId, correlationId, 300m, + PaymentRequestStatus.Submitted, paymentStatus: " Fully Paid "); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(2600m); // 1000 + 500 + 800 + 300 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Not_Count_PartialMatch_PaymentStatus_As_Paid() + { + // Arrange - "Paid" alone should not match "Fully Paid" + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 200m, + PaymentRequestStatus.Submitted, paymentStatus: "Paid"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(0m); // "Paid" != "Fully Paid" + } + + #endregion + + #region Pending Status Calculation + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Sum_All_Pending_Levels() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, + PaymentRequestStatus.L2Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, + PaymentRequestStatus.L3Pending); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(6000m); // 1000 + 2000 + 3000 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Include_Submitted_WithNullPaymentStatus_InPending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: null); + await InsertPaymentRequestAsync(siteId, correlationId, 300m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(800m); // 500 + 300 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Exclude_Submitted_WithErrorFromCas_FromPending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // This one has ErrorFromCas - should NOT be pending + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "Error"); + // This one has no error - SHOULD be pending + await InsertPaymentRequestAsync(siteId, correlationId, 200m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(200m); // Only the non-error one + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Exclude_Declined_Statuses_From_Both_Paid_And_Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.L1Declined); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, + PaymentRequestStatus.L2Declined); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, + PaymentRequestStatus.L3Declined); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(0m); + results[0].TotalPending.ShouldBe(0m); + } + + #endregion + + #region Mixed Paid and Pending + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Correctly_Separate_Paid_And_Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // Paid + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + await InsertPaymentRequestAsync(siteId, correlationId, 500m, + PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID"); + // Pending + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, + PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 800m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: null); + // Neither paid nor pending (declined) + await InsertPaymentRequestAsync(siteId, correlationId, 5000m, + PaymentRequestStatus.L1Declined); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPaid.ShouldBe(1500m); // 1000 + 500 + results[0].TotalPending.ShouldBe(2800m); // 2000 + 800 + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Return_Rollup_For_Multiple_CorrelationIds() + { + // Arrange + var app1Id = Guid.NewGuid(); + var app2Id = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // App 1 payments + await InsertPaymentRequestAsync(siteId, app1Id, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid"); + await InsertPaymentRequestAsync(siteId, app1Id, 500m, + PaymentRequestStatus.L1Pending); + // App 2 payments + await InsertPaymentRequestAsync(siteId, app2Id, 2000m, + PaymentRequestStatus.Submitted, paymentStatus: "fully paid"); + await InsertPaymentRequestAsync(siteId, app2Id, 300m, + PaymentRequestStatus.L2Pending); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([app1Id, app2Id]); + + // Assert + results.Count.ShouldBe(2); + + var app1Rollup = results.Find(r => r.ApplicationId == app1Id); + app1Rollup.ShouldNotBeNull(); + app1Rollup!.TotalPaid.ShouldBe(1000m); + app1Rollup.TotalPending.ShouldBe(500m); + + var app2Rollup = results.Find(r => r.ApplicationId == app2Id); + app2Rollup.ShouldNotBeNull(); + app2Rollup!.TotalPaid.ShouldBe(2000m); + app2Rollup.TotalPending.ShouldBe(300m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Return_Empty_For_Unknown_CorrelationIds() + { + // Arrange & Act + using var uow = _unitOfWorkManager.Begin(); + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([Guid.NewGuid()]); + + // Assert + results.ShouldBeEmpty(); + } + + #endregion + + #region Helpers + + private async Task CreateSupplierAndSiteAsync() + { + using var uow = _unitOfWorkManager.Begin(); + var siteId = Guid.NewGuid(); + var supplier = new Supplier(Guid.NewGuid(), "TestSupplier", "SUP-001", + new Correlation(Guid.NewGuid(), "Test")); + supplier.AddSite(new Site(siteId, "001", PaymentGroup.EFT)); + await _supplierRepository.InsertAsync(supplier, true); + await uow.CompleteAsync(); + return siteId; + } + + private async Task InsertPaymentRequestAsync( + Guid siteId, + Guid correlationId, + decimal amount, + PaymentRequestStatus status, + string? paymentStatus = null, + string? invoiceStatus = null) + { + var dto = new CreatePaymentRequestDto + { + InvoiceNumber = $"INV-{Guid.NewGuid():N}", + Amount = amount, + PayeeName = "Test Payee", + ContractNumber = "0000000000", + SupplierNumber = "SUP-001", + SiteId = siteId, + CorrelationId = correlationId, + CorrelationProvider = "Test", + ReferenceNumber = $"REF-{Guid.NewGuid():N}", + BatchName = "TEST_BATCH", + BatchNumber = 1 + }; + + var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); + paymentRequest.SetPaymentRequestStatus(status); + + if (paymentStatus != null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + + if (invoiceStatus != null) + { + paymentRequest.SetInvoiceStatus(invoiceStatus); + } + + await _paymentRequestRepository.InsertAsync(paymentRequest, true); + } + + #endregion +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs new file mode 100644 index 000000000..58b82f166 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs @@ -0,0 +1,847 @@ +using Shouldly; +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using Unity.Modules.Shared.Correlation; +using Unity.Payments.Codes; +using Unity.Payments.Domain.Suppliers; +using Unity.Payments.Enums; +using Unity.Payments.PaymentRequests; +using Volo.Abp.Uow; +using Xunit; + +namespace Unity.Payments.Domain.PaymentRequests; + +[Category("Integration")] +public class PaymentRequestRepository_Tests : PaymentsApplicationTestBase +{ + private readonly IPaymentRequestRepository _paymentRequestRepository; + private readonly ISupplierRepository _supplierRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public PaymentRequestRepository_Tests() + { + _paymentRequestRepository = GetRequiredService(); + _supplierRepository = GetRequiredService(); + _unitOfWorkManager = GetRequiredService(); + } + + #region GetCountByCorrelationId + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Return_Zero_When_No_Payments_Exist() + { + // Arrange + var correlationId = Guid.NewGuid(); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId); + + // Assert + count.ShouldBe(0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Return_Correct_Count_For_Single_Payment() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId); + + // Assert + count.ShouldBe(1); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Return_Correct_Count_For_Multiple_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId); + + // Assert + count.ShouldBe(3); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetCountByCorrelationId_Should_Not_Count_Other_CorrelationIds() + { + // Arrange + var correlationId1 = Guid.NewGuid(); + var correlationId2 = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId1); + + // Assert + count.ShouldBe(1); + } + + #endregion + + #region GetPaymentRequestCountBySiteId + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Return_Zero_When_No_Payments_Exist() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); + + // Assert + count.ShouldBe(0); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Return_Correct_Count_For_Single_Payment() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); + + // Assert + count.ShouldBe(1); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Return_Correct_Count_For_Multiple_Payments() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId1 = Guid.NewGuid(); + var correlationId2 = Guid.NewGuid(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L2Pending); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); + + // Assert + count.ShouldBe(2); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestCountBySiteId_Should_Not_Count_Other_Sites() + { + // Arrange + var siteId1 = await CreateSupplierAndSiteAsync(); + var siteId2 = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId1, correlationId, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId2, correlationId, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId1); + + // Assert + count.ShouldBe(1); + } + + #endregion + + #region GetPaymentRequestByInvoiceNumber + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Null_When_Not_Found() + { + // Arrange + var invoiceNumber = "NONEXISTENT-INV-001"; + + // Act + var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber); + + // Assert + payment.ShouldBeNull(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Payment_When_Found() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + var invoiceNumber = $"TEST-INV-{Guid.NewGuid():N}"; + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending, invoiceNumber); + + // Act + var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber); + + // Assert + payment.ShouldNotBeNull(); + payment.InvoiceNumber.ShouldBe(invoiceNumber); + payment.Amount.ShouldBe(1000m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Only_Matching_Invoice() + { + // Arrange + var siteId = await CreateSupplierAndSiteAsync(); + var correlationId = Guid.NewGuid(); + var invoiceNumber1 = $"TEST-INV-{Guid.NewGuid():N}"; + var invoiceNumber2 = $"TEST-INV-{Guid.NewGuid():N}"; + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending, invoiceNumber1); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending, invoiceNumber2); + + // Act + var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber1); + + // Assert + payment.ShouldNotBeNull(); + payment.InvoiceNumber.ShouldBe(invoiceNumber1); + payment.Amount.ShouldBe(1000m); + } + + #endregion + + #region GetTotalPaymentRequestAmountByCorrelationIdAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Return_Zero_When_No_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(0m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Sum_Single_Payment() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Sum_Multiple_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 2500m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.L1Pending); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(6500m); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L1Declined() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 5000m, PaymentRequestStatus.L1Declined); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // Declined amount excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L2Declined() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.L2Declined); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // Declined amount excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L3Declined() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L3Declined); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // Declined amount excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_NotFound_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 4000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.NotFound); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // NotFound invoice excluded + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_ErrorFromCas_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + await InsertPaymentRequestAsync(siteId, correlationId, 6000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Act + var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); + + // Assert + total.ShouldBe(1000m); // ErrorFromCas invoice excluded + } + + #endregion + + #region GetPaymentRequestsBySentToCasStatusAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_Empty_When_No_Matching_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.Validated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_ServiceUnavailable_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ServiceUnavailable); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_SentToCas_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.SentToCas); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.SentToCas); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_NeverValidated_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.NeverValidated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.NeverValidated); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_All_ReCheck_Statuses() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.SentToCas); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.NeverValidated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(3); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ServiceUnavailable).ShouldBeTrue(); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.SentToCas).ShouldBeTrue(); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.NeverValidated).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Not_Return_Null_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: null); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.SentToCas); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.SentToCas); + } + + #endregion + + #region GetPaymentRequestsByFailedsStatusAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_Empty_When_No_Matching_Payments() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.Validated); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_ServiceUnavailable_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + + // Update to trigger LastModificationTime + paymentRequest.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ServiceUnavailable); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_ErrorFromCas_Status() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Update to trigger LastModificationTime + paymentRequest.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ErrorFromCas); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_Both_Failed_Statuses() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest1 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable); + var paymentRequest2 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Update to trigger LastModificationTime + paymentRequest1.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest1, true); + paymentRequest2.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest2, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(2); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ServiceUnavailable).ShouldBeTrue(); + payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ErrorFromCas).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Not_Return_Null_InvoiceStatus() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + var paymentRequest1 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted, + invoiceStatus: null); + var paymentRequest2 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted, + invoiceStatus: CasPaymentRequestStatus.ErrorFromCas); + + // Update to trigger LastModificationTime (only update the one with ErrorFromCas) + paymentRequest2.SetCasResponse("Test response"); + await _paymentRequestRepository.UpdateAsync(paymentRequest2, true); + + // Act + var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync(); + + // Assert + payments.Count.ShouldBe(1); + payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ErrorFromCas); + } + + #endregion + + #region GetPaymentPendingListByCorrelationIdAsync + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_Empty_When_No_Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.ShouldBeEmpty(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_L1Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(1); + payments[0].Status.ShouldBe(PaymentRequestStatus.L1Pending); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_L2Pending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L2Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(1); + payments[0].Status.ShouldBe(PaymentRequestStatus.L2Pending); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_All_Pending_Statuses() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(2); + payments.Any(p => p.Status == PaymentRequestStatus.L1Pending).ShouldBeTrue(); + payments.Any(p => p.Status == PaymentRequestStatus.L2Pending).ShouldBeTrue(); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Not_Return_L3Pending() + { + // Arrange - The method only returns L1 and L2 pending + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L3Pending); + await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId); + + // Assert + payments.Count.ShouldBe(1); + payments[0].Status.ShouldBe(PaymentRequestStatus.L1Pending); + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Only_Return_Matching_CorrelationId() + { + // Arrange + var correlationId1 = Guid.NewGuid(); + var correlationId2 = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending); + await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L1Pending); + + // Act + var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId1); + + // Assert + payments.Count.ShouldBe(1); + payments[0].CorrelationId.ShouldBe(correlationId1); + } + + #endregion + + #region Helpers + + private async Task CreateSupplierAndSiteAsync() + { + using var uow = _unitOfWorkManager.Begin(); + var siteId = Guid.NewGuid(); + var supplier = new Supplier(Guid.NewGuid(), "TestSupplier", "SUP-001", + new Correlation(Guid.NewGuid(), "Test")); + supplier.AddSite(new Site(siteId, "001", PaymentGroup.EFT)); + await _supplierRepository.InsertAsync(supplier, true); + await uow.CompleteAsync(); + return siteId; + } + + private async Task InsertPaymentRequestAsync( + Guid siteId, + Guid correlationId, + decimal amount, + PaymentRequestStatus status, + string? customInvoiceNumber = null, + string? paymentStatus = null, + string? invoiceStatus = null) + { + await InsertAndGetPaymentRequestAsync(siteId, correlationId, amount, status, + customInvoiceNumber, paymentStatus, invoiceStatus); + } + + private async Task InsertAndGetPaymentRequestAsync( + Guid siteId, + Guid correlationId, + decimal amount, + PaymentRequestStatus status, + string? customInvoiceNumber = null, + string? paymentStatus = null, + string? invoiceStatus = null) + { + var invoiceNumber = customInvoiceNumber ?? $"INV-{Guid.NewGuid():N}"; + var dto = new CreatePaymentRequestDto + { + InvoiceNumber = invoiceNumber, + Amount = amount, + PayeeName = "Test Payee", + ContractNumber = "0000000000", + SupplierNumber = "SUP-001", + SiteId = siteId, + CorrelationId = correlationId, + CorrelationProvider = "Test", + ReferenceNumber = $"REF-{Guid.NewGuid():N}", + BatchName = "TEST_BATCH", + BatchNumber = 1 + }; + + var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto); + paymentRequest.SetPaymentRequestStatus(status); + + if (paymentStatus != null) + { + paymentRequest.SetPaymentStatus(paymentStatus); + } + + if (invoiceStatus != null) + { + paymentRequest.SetInvoiceStatus(invoiceStatus); + } + + await _paymentRequestRepository.InsertAsync(paymentRequest, true); + return paymentRequest; + } + + #endregion +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs index 0cd3660d8..58d70e1c0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs @@ -4,12 +4,8 @@ using NSubstitute; using Shouldly; using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Unity.Payments.Web.Views.Shared.Components.PaymentInfo; using Volo.Abp.DependencyInjection; @@ -28,7 +24,7 @@ public PaymentInfoViewComponentTests() } [Fact] - public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Current_Application() + public async Task PaymentInfo_Should_Display_TotalPaid_And_TotalPending_From_Rollup() { // Arrange var applicationId = Guid.NewGuid(); @@ -41,29 +37,25 @@ public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Cu RequestedAmount = 10000, RecommendedAmount = 8000, ApprovedAmount = 7000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var rollup = new ApplicationPaymentRollupDto { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending }, // Pending - new() { Amount = 1500, Status = PaymentRequestStatus.L1Declined }, // Not counted - new() { Amount = 800, Status = PaymentRequestStatus.Paid }, // Not counted (no PaymentStatus = "Fully Paid") + ApplicationId = applicationId, + TotalPaid = 1500m, + TotalPending = 2000m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(applicationId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(applicationId).Returns(paymentRequests); + paymentRequestService.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - applicationLinksService.GetListByApplicationAsync(applicationId).Returns([]); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(applicationId, applicationFormVersionId) as ViewViewComponentResult; @@ -71,338 +63,86 @@ public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Cu // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1500m); // 1000 + 500 (PaymentStatus = "Fully Paid") - model.TotalPendingAmounts.ShouldBe(2000m); // 2000 (L1Pending only) + model.TotalPaid.ShouldBe(1500m); + model.TotalPendingAmounts.ShouldBe(2000m); model.RemainingAmount.ShouldBe(5500m); // 7000 - 1500 } [Fact] - public async Task PaymentInfo_Should_Aggregate_TotalPaid_From_Child_Applications() + public async Task PaymentInfo_Should_Calculate_RemainingAmount_From_ApprovedAmount_Minus_TotalPaid() { // Arrange - var parentAppId = Guid.NewGuid(); - var childApp1Id = Guid.NewGuid(); - var childApp2Id = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var parentApplicationDto = new GrantApplicationDto - { - Id = parentAppId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childApp1Payments = new List - { - new() { CorrelationId = childApp1Id, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childApp2Payments = new List - { - new() { CorrelationId = childApp2Id, Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childLinks = new List - { - new() { ApplicationId = childApp1Id, LinkType = ApplicationLinkType.Child }, - new() { ApplicationId = childApp2Id, LinkType = ApplicationLinkType.Child } - }; - - var allChildPayments = childApp1Payments.Concat(childApp2Payments).ToList(); - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(allChildPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(2300m); // 1000 (parent) + 500 (child1) + 800 (child2) - } - - [Fact] - public async Task PaymentInfo_Should_Aggregate_TotalPendingAmounts_From_Child_Applications() - { - // Arrange - var parentAppId = Guid.NewGuid(); - var childApp1Id = Guid.NewGuid(); - var childApp2Id = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var parentApplicationDto = new GrantApplicationDto - { - Id = parentAppId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending } - }; - - var childApp1Payments = new List - { - new() { CorrelationId = childApp1Id, Amount = 1000, Status = PaymentRequestStatus.L1Pending } - }; - - var childApp2Payments = new List - { - new() { CorrelationId = childApp2Id, Amount = 500, Status = PaymentRequestStatus.L1Pending } - }; - - var childLinks = new List - { - new() { ApplicationId = childApp1Id, LinkType = ApplicationLinkType.Child }, - new() { ApplicationId = childApp2Id, LinkType = ApplicationLinkType.Child } - }; - - var allChildPayments = childApp1Payments.Concat(childApp2Payments).ToList(); - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(allChildPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(3500m); // 2000 (parent) + 1000 (child1) + 500 (child2) - } - - [Fact] - public async Task PaymentInfo_Should_Filter_Only_Child_LinkType() - { - // Arrange - var parentAppId = Guid.NewGuid(); - var childAppId = Guid.NewGuid(); - var relatedAppId = Guid.NewGuid(); - var parentLinkAppId = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var parentApplicationDto = new GrantApplicationDto - { - Id = parentAppId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childPayments = new List - { - new() { CorrelationId = childAppId, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var links = new List - { - new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child }, // Should be included - new() { ApplicationId = relatedAppId, LinkType = ApplicationLinkType.Related }, // Should be excluded - new() { ApplicationId = parentLinkAppId, LinkType = ApplicationLinkType.Parent } // Should be excluded - }; - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(links); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(childPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1500m); // 1000 (parent) + 500 (only child, not related or parent links) - } - - [Fact] - public async Task PaymentInfo_Should_Exclude_SelfReferences() - { - // Arrange - var appId = Guid.NewGuid(); - var childAppId = Guid.NewGuid(); + var applicationId = Guid.NewGuid(); var applicationFormVersionId = Guid.NewGuid(); var applicantId = Guid.NewGuid(); var applicationDto = new GrantApplicationDto { - Id = appId, + Id = applicationId, ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var appPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var childPayments = new List + var rollup = new ApplicationPaymentRollupDto { - new() { CorrelationId = childAppId, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var links = new List - { - new() { ApplicationId = appId, LinkType = ApplicationLinkType.Child }, // Self-reference - should be excluded - new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child } // Real child - should be included + ApplicationId = applicationId, + TotalPaid = 3500m, + TotalPending = 1000m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(appPayments); - applicationLinksService.GetListByApplicationAsync(appId).Returns(links); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(childPayments); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1500m); // 1000 (parent) + 500 (child only, self-reference excluded) - // Verify that GetListByApplicationIdsAsync was called with only the child app, not the self-reference - await paymentRequestService.Received(1).GetListByApplicationIdsAsync( - Arg.Is>(list => list.Count == 1 && list.Contains(childAppId) && !list.Contains(appId)) - ); - } - - [Fact] - public async Task PaymentInfo_Should_Handle_NoChildApplications() - { - // Arrange - var appId = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var applicationDto = new GrantApplicationDto - { - Id = appId, - RequestedAmount = 10000, - RecommendedAmount = 8000, - ApprovedAmount = 7000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var parentPayments = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, - new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending } - }; - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(appId).Returns(new List()); + appService.GetAsync(applicationId).Returns(applicationDto); + paymentRequestService.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; + var result = await viewComponent.InvokeAsync(applicationId, applicationFormVersionId) as ViewViewComponentResult; var model = result!.ViewData!.Model as PaymentInfoViewModel; // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1000m); // Only parent payments (PaymentStatus = "Fully Paid") - model.TotalPendingAmounts.ShouldBe(2000m); // 2000 (L1Pending only) - model.RemainingAmount.ShouldBe(6000m); // 7000 - 1000 - - // Verify that GetListByApplicationIdsAsync was NOT called since there are no children - await paymentRequestService.DidNotReceive().GetListByApplicationIdsAsync(Arg.Any>()); + model.RemainingAmount.ShouldBe(6500m); // 10000 - 3500 } [Fact] - public async Task PaymentInfo_Should_Handle_ChildApplications_WithNoPaymentRequests() + public async Task PaymentInfo_Should_Include_Child_Application_Amounts_By_Rollup() { + // The ViewComponent now delegates child aggregation to the service layer. + // This test verifies it correctly uses the pre-aggregated rollup. // Arrange var parentAppId = Guid.NewGuid(); - var childAppId = Guid.NewGuid(); var applicationFormVersionId = Guid.NewGuid(); var applicantId = Guid.NewGuid(); - var parentApplicationDto = new GrantApplicationDto + var applicationDto = new GrantApplicationDto { Id = parentAppId, ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var parentPayments = new List + // Rollup includes parent + child amounts (pre-aggregated by service) + var rollup = new ApplicationPaymentRollupDto { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" } - }; - - var childLinks = new List - { - new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child } + ApplicationId = parentAppId, + TotalPaid = 2300m, // e.g., 1000 (parent) + 500 (child1) + 800 (child2) + TotalPending = 3500m // e.g., 2000 (parent) + 1000 (child1) + 500 (child2) }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - appService.GetAsync(parentAppId).Returns(parentApplicationDto); - paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments); - applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks); - paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns([]); // Empty list + appService.GetAsync(parentAppId).Returns(applicationDto); + paymentRequestService.GetApplicationPaymentRollupAsync(parentAppId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult; @@ -410,12 +150,13 @@ public async Task PaymentInfo_Should_Handle_ChildApplications_WithNoPaymentReque // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(1000m); // Only parent payments (child has none) - model.TotalPendingAmounts.ShouldBe(0m); // No pending payments + model.TotalPaid.ShouldBe(2300m); + model.TotalPendingAmounts.ShouldBe(3500m); + model.RemainingAmount.ShouldBe(7700m); // 10000 - 2300 } [Fact] - public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending() + public async Task PaymentInfo_Should_Handle_Zero_Payments() { // Arrange var appId = Guid.NewGuid(); @@ -425,30 +166,26 @@ public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending() var applicationDto = new GrantApplicationDto { Id = appId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + ApprovedAmount = 5000, + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var rollup = new ApplicationPaymentRollupDto { - new() { Amount = 1000, Status = PaymentRequestStatus.L1Pending }, // Pending - new() { Amount = 500, Status = PaymentRequestStatus.Paid, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 2000, Status = PaymentRequestStatus.L1Declined }, // Not pending - new() { Amount = 1500, Status = PaymentRequestStatus.L2Declined }, // Not pending - new() { Amount = 1200, Status = PaymentRequestStatus.L3Declined } // Not pending + ApplicationId = appId, + TotalPaid = 0m, + TotalPending = 0m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); + paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; @@ -456,12 +193,13 @@ public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending() // Assert model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(1000m); // Only L1Pending status - model.TotalPaid.ShouldBe(500m); // PaymentStatus = "Fully Paid" + model.TotalPaid.ShouldBe(0m); + model.TotalPendingAmounts.ShouldBe(0m); + model.RemainingAmount.ShouldBe(5000m); // 5000 - 0 } [Fact] - public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensitive_WithSpaces() + public async Task PaymentInfo_Should_Map_RequestedAmount_And_RecommendedAmount() { // Arrange var appId = Guid.NewGuid(); @@ -471,30 +209,28 @@ public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensit var applicationDto = new GrantApplicationDto { Id = appId, + RequestedAmount = 15000, + RecommendedAmount = 12000, ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var rollup = new ApplicationPaymentRollupDto { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Exact match - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "FULLY PAID" }, // Upper case - new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = "fully paid" }, // Lower case - new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = " Fully Paid " }, // With spaces - new() { Amount = 200, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Paid" }, // Not fully paid + ApplicationId = appId, + TotalPaid = 0m, + TotalPending = 0m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); + paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; @@ -502,44 +238,28 @@ public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensit // Assert model.ShouldNotBeNull(); - model.TotalPaid.ShouldBe(2600m); // 1000 + 500 + 800 + 300 (all "Fully Paid" variations, excluding 200) + model.RequestedAmount.ShouldBe(15000); + model.RecommendedAmount.ShouldBe(12000); + model.ApprovedAmount.ShouldBe(10000); + model.ApplicationId.ShouldBe(appId); + model.ApplicationFormVersionId.ShouldBe(applicationFormVersionId); + model.ApplicantId.ShouldBe(applicantId); } [Fact] - public async Task PaymentInfo_Should_Include_Submitted_WithNullPaymentStatus_InPending() + public async Task PaymentInfo_Should_Return_Empty_View_When_Feature_Disabled() { // Arrange var appId = Guid.NewGuid(); var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); - - var applicationDto = new GrantApplicationDto - { - Id = appId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var paymentRequests = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = null }, // Pending - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending - new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "Validated" }, // Pending - new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = " " }, // Pending (whitespace) - new() { Amount = 200, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid", InvoiceStatus = "Validated" }, // Not pending (paid) - }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); + featureChecker.IsEnabledAsync("Unity.Payments").Returns(false); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; @@ -547,58 +267,17 @@ public async Task PaymentInfo_Should_Include_Submitted_WithNullPaymentStatus_InP // Assert model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(2600m); // 1000 + 500 + 800 + 300 (all with null PaymentStatus) - model.TotalPaid.ShouldBe(200m); // Only the one with PaymentStatus = "Fully Paid" - } - - [Fact] - public async Task PaymentInfo_Should_Exclude_Submitted_WithError_InvoiceStatus_FromPending() - { - // Arrange - var appId = Guid.NewGuid(); - var applicationFormVersionId = Guid.NewGuid(); - var applicantId = Guid.NewGuid(); + model.TotalPaid.ShouldBeNull(); + model.TotalPendingAmounts.ShouldBeNull(); + model.RemainingAmount.ShouldBeNull(); - var applicationDto = new GrantApplicationDto - { - Id = appId, - ApprovedAmount = 10000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } - }; - - var paymentRequests = new List - { - new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "ErrorFromCas" }, // Not pending (error) - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = " Error " }, // Not pending (error with spaces) - new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "ServiceUnavailable" }, // Pending (will be retried) - new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending (no error) - new() { Amount = 200, Status = PaymentRequestStatus.L1Pending }, // Pending (L1) - }; - - var appService = Substitute.For(); - var paymentRequestService = Substitute.For(); - var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); - - appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); - featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); - - // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; - - // Assert - model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(1300m); // 800 (ServiceUnavailable - will retry) + 300 (SentToCas) + 200 (L1Pending) - model.TotalPaid.ShouldBe(0m); // None with PaymentStatus = "Fully Paid" + // Verify no service calls were made + await appService.DidNotReceive().GetAsync(Arg.Any()); + await paymentRequestService.DidNotReceive().GetApplicationPaymentRollupAsync(Arg.Any()); } [Fact] - public async Task PaymentInfo_Should_Include_All_Pending_Levels_InPending() + public async Task PaymentInfo_Should_Call_GetApplicationPaymentRollupAsync_With_ApplicationId() { // Arrange var appId = Guid.NewGuid(); @@ -608,49 +287,38 @@ public async Task PaymentInfo_Should_Include_All_Pending_Levels_InPending() var applicationDto = new GrantApplicationDto { Id = appId, - ApprovedAmount = 20000, - Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId } + ApprovedAmount = 5000, + Applicant = new GrantApplicationApplicantDto { Id = applicantId } }; - var paymentRequests = new List + var rollup = new ApplicationPaymentRollupDto { - new() { Amount = 1000, Status = PaymentRequestStatus.L1Pending }, // Pending - new() { Amount = 2000, Status = PaymentRequestStatus.L2Pending }, // Pending - new() { Amount = 3000, Status = PaymentRequestStatus.L3Pending }, // Pending - new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending - new() { Amount = 4000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid - new() { Amount = 100, Status = PaymentRequestStatus.L1Declined }, // Not pending - new() { Amount = 200, Status = PaymentRequestStatus.L2Declined }, // Not pending - new() { Amount = 300, Status = PaymentRequestStatus.L3Declined }, // Not pending + ApplicationId = appId, + TotalPaid = 100m, + TotalPending = 200m }; var appService = Substitute.For(); var paymentRequestService = Substitute.For(); var featureChecker = Substitute.For(); - var applicationLinksService = Substitute.For(); appService.GetAsync(appId).Returns(applicationDto); - paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests); - applicationLinksService.GetListByApplicationAsync(appId).Returns([]); + paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup); featureChecker.IsEnabledAsync("Unity.Payments").Returns(true); - var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService); + var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker); // Act - var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult; - var model = result!.ViewData!.Model as PaymentInfoViewModel; + await viewComponent.InvokeAsync(appId, applicationFormVersionId); - // Assert - model.ShouldNotBeNull(); - model.TotalPendingAmounts.ShouldBe(6500m); // 1000 (L1) + 2000 (L2) + 3000 (L3) + 500 (Submitted with null PaymentStatus) - model.TotalPaid.ShouldBe(4000m); // Only PaymentStatus = "Fully Paid" + // Assert - Verify the correct service method was called with the right ID + await paymentRequestService.Received(1).GetApplicationPaymentRollupAsync(appId); } private PaymentInfoViewComponent CreateViewComponent( IGrantApplicationAppService appService, IPaymentRequestAppService paymentRequestService, - IFeatureChecker featureChecker, - IApplicationLinksService applicationLinksService) + IFeatureChecker featureChecker) { var viewContext = new ViewContext { @@ -663,10 +331,9 @@ private PaymentInfoViewComponent CreateViewComponent( }; var viewComponent = new PaymentInfoViewComponent( - appService, + appService, paymentRequestService, - featureChecker, - applicationLinksService) + featureChecker) { ViewComponentContext = viewComponentContext, LazyServiceProvider = _lazyServiceProvider diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs index a64ffdf37..b223ff74d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/PaymentsTestBase.cs @@ -1,19 +1,20 @@ -using System; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using System; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.Notifications.EmailGroups; +using Unity.Payments.Security; using Volo.Abp; -using Volo.Abp.Modularity; -using Volo.Abp.Uow; -using Volo.Abp.Testing; -using NSubstitute; using Volo.Abp.Features; -using Volo.Abp.Users; -using Unity.Payments.Security; +using Volo.Abp.Identity; +using Volo.Abp.Modularity; using Volo.Abp.SettingManagement; using Volo.Abp.TenantManagement; -using Unity.GrantManager.Applications; -using Volo.Abp.Identity; -using Unity.Notifications.EmailGroups; +using Volo.Abp.Testing; +using Volo.Abp.Uow; +using Volo.Abp.Users; namespace Unity.Payments; @@ -72,9 +73,10 @@ protected override void AfterAddApplication(IServiceCollection services) featureMock.IsEnabledAsync(Arg.Any()).Returns(true); services.AddSingleton(featureMock); - // Mock the repositories to avoid database access + // Mock the repositories and services to avoid database access services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); + services.AddSingleton(Substitute.For()); var externalUserLookupMock = Substitute.For(); services.AddSingleton(externalUserLookupMock); @@ -82,8 +84,8 @@ protected override void AfterAddApplication(IServiceCollection services) var currentUser = Substitute.For(); currentUser.Id.Returns(ci => CurrentUserId); services.AddSingleton(currentUser); - - // We add a mock of this service to satisfy the IOC without having to spin up a whole settings table + + // We add a mock of this service to satisfy the IOC without having to spin up a whole settings table var settingManagerMock = Substitute.For(); // Mock required calls services.AddSingleton(settingManagerMock); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs index 70d733e5a..432c73904 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs @@ -49,5 +49,6 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files.Add("/themes/ux2/plugins/filterRow.js"); context.Files.Add("/themes/ux2/plugins/colvisAlpha.js"); context.Files.Add("/themes/ux2/table-utils.js"); + context.Files.Add("/js/DateUtils.js"); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/DateUtils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js similarity index 88% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/DateUtils.js rename to applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js index 705038145..651e25ebc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/wwwroot/js/DateUtils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js @@ -9,11 +9,11 @@ const DateUtils = (function () { * @param {string|Date} dateUtc - The UTC date to format * @param {string} type - The type of formatting (for DataTables compatibility) * @param {object} options - Additional formatting options - * @returns {string|number} Formatted date string or timestamp for sorting + * @returns {string|number|null} Formatted date string or timestamp for sorting, null if input is invalid */ function formatUtcDateToLocal(dateUtc, type, options) { if (!dateUtc) { - return ''; + return null; } const date = new Date(dateUtc); 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/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js index 781616ad2..f536bb271 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/filterRow.js @@ -389,6 +389,9 @@ $(externalSearchId).val(''); } + // Clear the search input field + $('#search').val(''); + // Clear custom filter inputs $('.custom-filter-input').val(''); @@ -398,8 +401,9 @@ // Clear order dt.order(initialSortOrder); - // Reload data - dt.ajax.reload(); + // If we want to reset quick date range dropdown to default (last 6 months) and trigger change + // The change event handler will reload the table, so would need to remove ajax.reload() here + $('#quickDateRange').val($('#quickDateRange option[selected]').val()).trigger('change'); // Update button state this._updateButtonState(); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js index 0d3372b1b..2e9125f45 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js @@ -216,6 +216,9 @@ function initializeDataTable(options) { externalSearchId = 'search', disableColumnSelect = false, listColumnDefs, + onStateSaveParams,//External hooks for save/load/loaded + onStateLoadParams, + onStateLoaded, } = options; // Process columns and visibility @@ -314,15 +317,29 @@ function initializeDataTable(options) { processing: true, stateSave: true, stateDuration: 0, + externalSearchInputId: `#${externalSearchId}`, + onStateSaveParams, + onStateLoadParams, + onStateLoaded, stateSaveParams: function (settings, data) { let externalSearch = $(settings.oInit.externalSearchInputId); if (externalSearch.length) data.externalSearch = externalSearch.val(); + + // Call custom stateSave hook if provided + if (typeof settings.oInit.onStateSaveParams === 'function') { + settings.oInit.onStateSaveParams(settings, data); + } }, stateLoadParams: function (settings, data) { if (data.externalSearch) { let externalSearch = $(settings.oInit.externalSearchInputId); if (externalSearch.length) externalSearch.val(data.externalSearch); } + + // Call custom stateLoad hook if provided + if (typeof settings.oInit.onStateLoadParams === 'function') { + settings.oInit.onStateLoadParams(settings, data); + } }, stateLoaded: function (settings, data) { let dtApi = new $.fn.dataTable.Api(settings); @@ -360,6 +377,11 @@ function initializeDataTable(options) { } } } + + // Call custom loaded hook if provided + if (typeof settings.oInit.onStateLoaded === 'function') { + settings.oInit.onStateLoaded(dtApi, data); + } }, }); 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..3db1d7dcd --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/IApplicantProfileContactService.cs @@ -0,0 +1,39 @@ +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 three sources: profile-linked contacts, +/// application-level contacts matched by OIDC subject, and applicant agent +/// contacts derived from the submission login token. +/// +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); + + /// + /// Retrieves contacts derived from applicant agents on applications whose form submissions + /// match the given OIDC subject. The join path is Submission → Application → ApplicantAgent. + /// 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> GetApplicantAgentContactsBySubjectAsync(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/ApplicantProfile/ProfileData/AddressInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/AddressInfoItemDto.cs new file mode 100644 index 000000000..5ad2a67c1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/AddressInfoItemDto.cs @@ -0,0 +1,20 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class AddressInfoItemDto + { + public Guid Id { get; set; } + public string AddressType { get; set; } = string.Empty; + public string Street { get; set; } = string.Empty; + public string Street2 { get; set; } = string.Empty; + public string Unit { get; set; } = string.Empty; + public string City { get; set; } = string.Empty; + public string Province { get; set; } = string.Empty; + public string PostalCode { get; set; } = string.Empty; + public string Country { get; set; } = string.Empty; + public bool IsPrimary { get; set; } + public bool IsEditable { get; set; } + public string? ReferenceNo { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs new file mode 100644 index 000000000..f7b956aba --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantAddressInfoDto.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class ApplicantAddressInfoDto : ApplicantProfileDataDto + { + public override string DataType => "ADDRESSINFO"; + + public List Addresses { get; set; } = []; + } +} 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/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs new file mode 100644 index 000000000..9c1fc36c7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantSubmissionInfoDto.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class ApplicantSubmissionInfoDto : ApplicantProfileDataDto + { + public override string DataType => "SUBMISSIONINFO"; + + public List Submissions { get; set; } = []; + public string LinkSource { get; set; } = string.Empty; + } +} 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..112eed817 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ContactInfoItemDto.cs @@ -0,0 +1,22 @@ +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; } + public string? ReferenceNo { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs new file mode 100644 index 000000000..bc1b54c02 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class SubmissionInfoItemDto + { + public Guid Id { get; set; } + public string LinkId { get; set; } = string.Empty; + public DateTime ReceivedTime { get; set; } + public DateTime SubmissionTime { get; set; } + public string ReferenceNo { get; set; } = string.Empty; + public string ProjectName { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs deleted file mode 100644 index fde1734a0..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantAddressInfoDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Unity.GrantManager.Applicants.ProfileData -{ - public class ApplicantAddressInfoDto : ApplicantProfileDataDto - { - public override string DataType => "ADDRESSINFO"; - } -} 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/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs deleted file mode 100644 index 4c0a0ba60..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ProfileData/ApplicantSubmissionInfoDto.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Unity.GrantManager.Applicants.ProfileData -{ - public class ApplicantSubmissionInfoDto : ApplicantProfileDataDto - { - public override string DataType => "SUBMISSIONINFO"; - } -} 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.Contracts/GrantApplications/IApplicationLinksService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationLinksService.cs index 833fa4232..c8df0b91a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationLinksService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationLinksService.cs @@ -11,6 +11,9 @@ public interface IApplicationLinksService : ICrudAppService< Guid> { Task> GetListByApplicationAsync(Guid applicationId); + Task> GetApplicationLinksByType(Guid applicationId, ApplicationLinkType linkType); + Task> GetChildApplications(Guid applicationId); + Task>> GetChildApplicationIdsByParentIdsAsync(List parentApplicationIds); Task GetLinkedApplicationAsync(Guid currentApplicationId, Guid linkedApplicationId); Task GetCurrentApplicationInfoAsync(Guid applicationId); Task DeleteWithPairAsync(Guid applicationLinkId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 1374af052..ad7786c4b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -19,11 +19,23 @@ public class OpenAIService : IAIService, ITransientDependency private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; - private string? ApiKey => _configuration["AI:OpenAI:ApiKey"]; - private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; - private readonly string NoKeyError = "OpenAI API key is not configured"; - - public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) + private string? ApiKey => _configuration["Azure:OpenAI:ApiKey"]; + private string? ApiUrl => _configuration["Azure:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; + private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; + + // Optional local debugging sink for prompt payload logs to a local file. + // Not intended for deployed/shared environments. + private bool IsPromptFileLoggingEnabled => _configuration.GetValue("Azure:Logging:EnablePromptFileLog") ?? false; + private const string PromptLogDirectoryName = "logs"; + private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log"; + + private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + + public OpenAIService( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger, + ITextExtractionService textExtractionService) { _httpClient = httpClient; _configuration = configuration; @@ -35,7 +47,7 @@ public Task IsAvailableAsync() { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("Error: {Message}", NoKeyError); + _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return Task.FromResult(false); } @@ -46,22 +58,23 @@ public async Task GenerateSummaryAsync(string content, string? prompt = { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("Error: {Message}", NoKeyError); + _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return "AI analysis not available - service not configured."; } - _logger.LogDebug("Calling OpenAI with prompt: {Prompt}", content); + _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); try { var systemPrompt = prompt ?? "You are a professional grant analyst for the BC Government."; + var userPrompt = content ?? string.Empty; var requestBody = new { messages = new[] { new { role = "system", content = systemPrompt }, - new { role = "user", content = content } + new { role = "user", content = userPrompt } }, max_tokens = maxTokens, temperature = 0.3 @@ -76,7 +89,10 @@ public async Task GenerateSummaryAsync(string content, string? prompt = var response = await _httpClient.PostAsync(ApiUrl, httpContent); var responseContent = await response.Content.ReadAsStringAsync(); - _logger.LogDebug("Response: {Response}", responseContent); + _logger.LogDebug( + "OpenAI chat completions response received. StatusCode: {StatusCode}, ResponseLength: {ResponseLength}", + response.StatusCode, + responseContent?.Length ?? 0); if (!response.IsSuccessStatusCode) { @@ -84,6 +100,11 @@ public async Task GenerateSummaryAsync(string content, string? prompt = return "AI analysis failed - service temporarily unavailable."; } + if (string.IsNullOrWhiteSpace(responseContent)) + { + return "No summary generated."; + } + using var jsonDoc = JsonDocument.Parse(responseContent); var choices = jsonDoc.RootElement.GetProperty("choices"); if (choices.GetArrayLength() > 0) @@ -107,25 +128,47 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] { var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); - string contentToAnalyze; - string prompt; + var prompt = @"ROLE +You are a professional grant analyst for the BC Government. + +TASK +Produce a concise reviewer-facing summary of the provided attachment context. - if (!string.IsNullOrWhiteSpace(extractedText)) +OUTPUT +- Plain text only +- 1-2 complete sentences + +RULES +- Use only the provided attachment context as evidence. +- If text content is present, summarize the actual content. +- If text content is missing or empty, provide a conservative metadata-based summary. +- Do not invent missing details. +- Keep the summary specific, concrete, and reviewer-facing. +- Return plain text only (no markdown, bullets, or JSON)."; + + var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; + if (attachmentText != null) { _logger.LogDebug("Extracted {TextLength} characters from {FileName}", extractedText.Length, fileName); - - contentToAnalyze = $"Document: {fileName}\nType: {contentType}\nContent:\n{extractedText}"; - prompt = "Please analyze this document and provide a concise summary of its content, purpose, and key information, for use by your fellow grant analysts. It should be 1-2 sentences long and about 46 tokens."; } else { _logger.LogDebug("No text extracted from {FileName}, analyzing metadata only", fileName); - - contentToAnalyze = $"File: {fileName}, Type: {contentType}, Size: {fileContent.Length} bytes"; - prompt = "Please analyze this document and provide a concise summary of its content, purpose, and key information, for use by your fellow grant analysts. It should be 1-2 sentences long and about 46 tokens."; } - return await GenerateSummaryAsync(contentToAnalyze, prompt, 150); + var attachmentPayload = new + { + name = fileName, + contentType, + sizeBytes = fileContent.Length, + text = attachmentText + }; + var contentToAnalyze = $"ATTACHMENT\n{JsonSerializer.Serialize(attachmentPayload, JsonLogOptions)}"; + + await LogPromptInputAsync("AttachmentSummary", prompt, contentToAnalyze); + var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); + await LogPromptOutputAsync("AttachmentSummary", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -138,46 +181,76 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "AI analysis not available - service not configured."; } try { - var attachmentSummariesText = attachmentSummaries?.Count > 0 - ? string.Join("\n- ", attachmentSummaries.Select((s, i) => $"Attachment {i + 1}: {s}")) - : "No attachments provided."; + object schemaPayload = new { }; + if (!string.IsNullOrWhiteSpace(formFieldConfiguration)) + { + try + { + using var schemaDoc = JsonDocument.Parse(formFieldConfiguration); + schemaPayload = schemaDoc.RootElement.Clone(); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Invalid form field configuration JSON. Using empty schema payload."); + } + } - var fieldConfigurationSection = !string.IsNullOrEmpty(formFieldConfiguration) - ? $@" -{formFieldConfiguration}" - : string.Empty; + var dataPayload = new + { + applicationContent + }; - var analysisContent = $@"APPLICATION CONTENT: -{applicationContent} + var attachmentsPayload = attachmentSummaries?.Count > 0 + ? attachmentSummaries + .Select((summary, index) => new + { + name = $"Attachment {index + 1}", + summary = summary + }) + .Cast() + : Enumerable.Empty(); -ATTACHMENT SUMMARIES: -- {attachmentSummariesText} -{fieldConfigurationSection} + var analysisContent = $@"SCHEMA +{JsonSerializer.Serialize(schemaPayload, JsonLogOptions)} + +DATA +{JsonSerializer.Serialize(dataPayload, JsonLogOptions)} + +ATTACHMENTS +{JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions)} -EVALUATION RUBRIC: +RUBRIC {rubric} -Analyze this grant application comprehensively across all five rubric categories (Eligibility, Completeness, Financial Review, Risk Assessment, and Quality Indicators). Identify issues, concerns, and areas for improvement. Return your findings in the following JSON format: +SEVERITY +ERROR: Issue that would likely prevent the application from being approved. +WARNING: Issue that could negatively affect the application's approval. +RECOMMENDATION: Reviewer-facing improvement or follow-up consideration. + +SCORE +HIGH: Application demonstrates strong evidence across most rubric areas with few or no issues. +MEDIUM: Application has some gaps or weaknesses that require reviewer attention. +LOW: Application has significant gaps or risks across key rubric areas. + +OUTPUT {{ ""overall_score"": ""HIGH/MEDIUM/LOW"", ""warnings"": [ {{ ""category"": ""Brief summary of the warning"", - ""message"": ""Detailed warning message with full context and explanation"", - ""severity"": ""WARNING"" + ""message"": ""Detailed warning message with full context and explanation"" }} ], ""errors"": [ {{ ""category"": ""Brief summary of the error"", - ""message"": ""Detailed error message with full context and explanation"", - ""severity"": ""ERROR"" + ""message"": ""Detailed error message with full context and explanation"" }} ], ""recommendations"": [ @@ -188,21 +261,34 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis ] }} -Important: The 'category' field should be a concise summary (3-6 words) that captures the essence of the issue, while the 'message' field should contain the detailed explanation."; - - var systemPrompt = @"You are an expert grant application reviewer for the BC Government. - -Conduct a thorough, comprehensive analysis across all rubric categories. Identify substantive issues, concerns, and opportunities for improvement. - -Classify findings based on their impact on the application's evaluation and fundability: -- ERRORS: Important missing information, significant gaps in required content, compliance issues, or major concerns affecting eligibility -- WARNINGS: Areas needing clarification, moderate issues, or concerns that should be addressed - -Evaluate the quality, clarity, and appropriateness of all application content. Be thorough but fair - identify real issues while avoiding nitpicking. - -Respond only with valid JSON in the exact format requested."; - +RULES +- Use only SCHEMA, DATA, ATTACHMENTS, and RUBRIC as evidence. +- Do not invent fields, documents, requirements, or facts. +- Treat missing or empty values as findings only when they weaken rubric evidence. +- Prefer material issues; avoid nitpicking. +- Each error/warning/recommendation must describe one concrete issue or consideration and why it matters. +- Use 3-6 words for category. +- Each message must be 1-2 complete sentences. +- Each message must be grounded in concrete evidence from provided inputs. +- If attachment evidence is used, reference the attachment explicitly in the message. +- Do not provide applicant-facing advice. +- Do not mention rubric section names in findings. +- If no findings exist, return empty arrays. +- overall_score must be HIGH, MEDIUM, or LOW. +- Return values exactly as specified in OUTPUT. +- Do not return keys outside OUTPUT. +- Return valid JSON only. +- Return plain JSON only (no markdown)."; + + var systemPrompt = @"ROLE +You are an expert grant analyst assistant for human reviewers. + +TASK +Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SEVERITY, SCORE, OUTPUT, and RULES, return review findings."; + + await LogPromptInputAsync("ApplicationAnalysis", systemPrompt, analysisContent); var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); + await LogPromptOutputAsync("ApplicationAnalysis", rawAnalysis); // Post-process the AI response to add unique IDs to errors and warnings return AddIdsToAnalysisItems(rawAnalysis); @@ -279,7 +365,7 @@ public async Task GenerateScoresheetAnswersAsync(string applicationConte { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "{}"; } @@ -320,7 +406,10 @@ Base your answers on the application content and attachment summaries provided. Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. Respond only with valid JSON in the exact format requested."; - return await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + await LogPromptInputAsync("ScoresheetAll", systemPrompt, analysisContent); + var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + await LogPromptOutputAsync("ScoresheetAll", modelOutput); + return modelOutput; } catch (Exception ex) { @@ -333,7 +422,7 @@ public async Task GenerateScoresheetSectionAnswersAsync(string applicati { if (string.IsNullOrEmpty(ApiKey)) { - _logger.LogWarning("{Message}", NoKeyError); + _logger.LogWarning("{Message}", MissingApiKeyMessage); return "{}"; } @@ -343,64 +432,234 @@ public async Task GenerateScoresheetSectionAnswersAsync(string applicati ? string.Join("\n- ", attachmentSummaries.Select((s, i) => $"Attachment {i + 1}: {s}")) : "No attachments provided."; - var analysisContent = $@"APPLICATION CONTENT: -{applicationContent} - -ATTACHMENT SUMMARIES: -- {attachmentSummariesText} + object sectionQuestionsPayload = sectionJson; + if (!string.IsNullOrWhiteSpace(sectionJson)) + { + try + { + using var sectionDoc = JsonDocument.Parse(sectionJson); + sectionQuestionsPayload = sectionDoc.RootElement.Clone(); + } + catch (JsonException) + { + // Keep raw string payload when JSON parsing fails. + } + } -SCORESHEET SECTION: {sectionName} -{sectionJson} + var sectionPayload = new + { + name = sectionName, + questions = sectionQuestionsPayload + }; -Please analyze this grant application and provide appropriate answers for each question in the ""{sectionName}"" section only. + var analysisContent = $@"DATA +{applicationContent} -For each question, provide: -1. Your answer based on the application content -2. A brief cited description (1-2 sentences) explaining your reasoning with specific references to the application content -3. A confidence score from 0-100 indicating how confident you are in your answer based on available information +ATTACHMENTS +- {attachmentSummariesText} -Guidelines for answers: -- For numeric questions, provide a numeric value within the specified range -- For yes/no questions, provide either 'Yes' or 'No' -- For text questions, provide a concise, relevant response -- For select list questions, respond with ONLY the number from the 'number' field (1, 2, 3, etc.) of your chosen option. NEVER return 0 - the lowest valid answer is 1. For example: if you want '(0 pts) No outcomes provided', choose the option where number=1, not 0. -- For text area questions, provide a detailed but concise response -- Base your confidence score on how clearly the application content supports your answer +SECTION +{JsonSerializer.Serialize(sectionPayload, JsonLogOptions)} -Return your response as a JSON object where each key is the question ID and the value contains the answer, citation, and confidence: +RESPONSE {{ - ""question-id-1"": {{ - ""answer"": ""your-answer-here"", - ""citation"": ""Brief explanation with specific reference to application content"", + """": {{ + ""answer"": """", + ""rationale"": """", ""confidence"": 85 - }}, - ""question-id-2"": {{ - ""answer"": ""3"", - ""citation"": ""Based on the project budget of $50,000 mentioned in the application, this falls into the medium budget category"", - ""confidence"": 90 }} }} -IMPORTANT FOR SELECT LIST QUESTIONS: If a question has availableOptions like: -[{{""number"":1,""value"":""Low (Under $25K)""}}, {{""number"":2,""value"":""Medium ($25K-$75K)""}}, {{""number"":3,""value"":""High (Over $75K)""}}] -Then respond with ONLY the number (e.g., ""3"" for ""High (Over $75K)""), not the text value. +RULES +- Use only DATA and ATTACHMENTS as evidence. +- Do not invent missing application details. +- Return exactly one answer object per question ID in SECTION.questions. +- Do not omit any question IDs from SECTION.questions. +- Do not add keys that are not question IDs from SECTION.questions. +- Use RESPONSE as the output contract and fill every placeholder value. +- Each answer object must include: answer, rationale, confidence. +- answer type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. +- For yes/no questions, answer must be exactly ""Yes"" or ""No"". +- For numeric questions, answer must be a numeric value within the allowed range. +- For select list questions, answer must be the selected availableOptions.number encoded as a string. +- For select list questions, never return option label text (for example: ""Yes"", ""No"", or ""N/A""); return the option number string. +- For text and text area questions, answer must be concise, grounded in evidence, and non-empty. +- rationale must be 1-2 complete sentences grounded in concrete DATA/ATTACHMENTS evidence. +- For every question, rationale must justify both the selected answer and confidence level based on evidence strength. +- If evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. +- confidence must be an integer from 0 to 100. +- Confidence reflects certainty in the selected answer given available evidence, not application quality. +- Return values exactly as specified in RESPONSE. +- Do not return keys outside RESPONSE. +- Return valid JSON only. +- Return plain JSON only (no markdown)."; + + var systemPrompt = @"ROLE +You are an expert grant application reviewer for the BC Government. + +TASK +Using DATA, ATTACHMENTS, SECTION, RESPONSE, and RULES, answer only the questions in SECTION."; + + await LogPromptInputAsync("ScoresheetSection", systemPrompt, analysisContent); + var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + await LogPromptOutputAsync("ScoresheetSection", modelOutput); + return modelOutput; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", sectionName); + return "{}"; + } + } -Do not return any markdown formatting, just the JSON by itself"; + private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) + { + var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); + _logger.LogInformation("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); + await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); + } - var systemPrompt = @"You are an expert grant application reviewer for the BC Government. -Analyze the provided application and generate appropriate answers for the scoresheet section questions based on the application content. -Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. -Always provide citations that reference specific parts of the application content to support your answers. -Be honest about your confidence level - if information is missing or unclear, reflect this in a lower confidence score. -Respond only with valid JSON in the exact format requested."; + private async Task LogPromptOutputAsync(string promptType, string output) + { + var formattedOutput = FormatPromptOutputForLog(output); + _logger.LogInformation("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); + await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); + } - return await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + private async Task WritePromptLogFileAsync(string promptType, string payloadType, string payload) + { + if (!CanWritePromptFileLog()) + { + return; + } + + try + { + var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); + var logDirectory = Path.Combine(AppContext.BaseDirectory, PromptLogDirectoryName); + Directory.CreateDirectory(logDirectory); + + var logPath = Path.Combine(logDirectory, PromptLogFileName); + var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + await File.AppendAllTextAsync(logPath, entry); } catch (Exception ex) { - _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", sectionName); - return "{}"; + _logger.LogWarning(ex, "Failed to write AI prompt log file."); + } + } + + private bool CanWritePromptFileLog() + { + return IsPromptFileLoggingEnabled; + } + + private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) + { + var normalizedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? string.Empty : systemPrompt.Trim(); + var normalizedUserPrompt = string.IsNullOrWhiteSpace(userPrompt) ? string.Empty : userPrompt.Trim(); + return $"SYSTEM_PROMPT\n{normalizedSystemPrompt}\n\nUSER_PROMPT\n{normalizedUserPrompt}"; + } + + private static string FormatPromptOutputForLog(string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return string.Empty; + } + + if (TryParseJsonObjectFromResponse(output, out var jsonObject)) + { + return JsonSerializer.Serialize(jsonObject, JsonLogOptions); + } + + return output.Trim(); + } + + private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) + { + objectElement = default; + var cleaned = CleanJsonResponse(response); + if (string.IsNullOrWhiteSpace(cleaned)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(cleaned); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + objectElement = doc.RootElement.Clone(); + return true; } + catch (JsonException) + { + return false; + } + } + + private static string CleanJsonResponse(string response) + { + if (string.IsNullOrWhiteSpace(response)) + { + return string.Empty; + } + + var cleaned = response.Trim(); + + if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) + { + var startIndex = cleaned.IndexOf('\n'); + if (startIndex >= 0) + { + // Multi-line fenced code block: remove everything up to and including the first newline. + cleaned = cleaned[(startIndex + 1)..]; + } + else + { + // Single-line fenced JSON, e.g. ```json { ... } ``` or ```{ ... } ```. + // Strip everything before the first likely JSON payload token. + var jsonStart = FindFirstJsonTokenIndex(cleaned); + + if (jsonStart > 0) + { + cleaned = cleaned[jsonStart..]; + } + } + } + + if (cleaned.EndsWith("```", StringComparison.Ordinal)) + { + var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); + if (lastIndex > 0) + { + cleaned = cleaned[..lastIndex]; + } + } + + return cleaned.Trim(); + } + + private static int FindFirstJsonTokenIndex(string value) + { + var objectStart = value.IndexOf('{'); + var arrayStart = value.IndexOf('['); + + if (objectStart >= 0 && arrayStart >= 0) + { + return Math.Min(objectStart, arrayStart); + } + + if (objectStart >= 0) + { + return objectStart; + } + + return arrayStart; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs index 28d5af2b4..d55efddd1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs @@ -1,14 +1,18 @@ using Microsoft.Extensions.Logging; using System; using System.IO; +using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using UglyToad.PdfPig; using Volo.Abp.DependencyInjection; namespace Unity.GrantManager.AI { - public class TextExtractionService : ITextExtractionService, ITransientDependency + public partial class TextExtractionService : ITextExtractionService, ITransientDependency { + private const int MaxExtractedTextLength = 50000; private readonly ILogger _logger; public TextExtractionService(ILogger logger) @@ -30,6 +34,8 @@ public async Task ExtractTextAsync(string fileName, byte[] fileContent, var normalizedContentType = contentType?.ToLowerInvariant() ?? string.Empty; var extension = Path.GetExtension(fileName)?.ToLowerInvariant() ?? string.Empty; + string rawText; + // Handle text-based files if (normalizedContentType.Contains("text/") || extension == ".txt" || @@ -37,15 +43,15 @@ public async Task ExtractTextAsync(string fileName, byte[] fileContent, extension == ".json" || extension == ".xml") { - return await ExtractTextFromTextFileAsync(fileContent); + rawText = await ExtractTextFromTextFileAsync(fileContent); + return NormalizeAndLimitText(rawText, fileName); } // Handle PDF files if (normalizedContentType.Contains("pdf") || extension == ".pdf") { - // For now, return empty string - can be enhanced with PDF parsing library - _logger.LogDebug("PDF text extraction not yet implemented for {FileName}", fileName); - return string.Empty; + rawText = await Task.FromResult(ExtractTextFromPdfFile(fileName, fileContent)); + return NormalizeAndLimitText(rawText, fileName); } // Handle Word documents @@ -97,12 +103,11 @@ private async Task ExtractTextFromTextFileAsync(byte[] fileContent) text = Encoding.ASCII.GetString(fileContent); } - // Limit the extracted text to a reasonable size (e.g., first 50,000 characters) - const int maxLength = 50000; - if (text.Length > maxLength) + // Limit the extracted text to a reasonable size. + if (text.Length > MaxExtractedTextLength) { - text = text.Substring(0, maxLength); - _logger.LogDebug("Truncated text content to {MaxLength} characters", maxLength); + text = text.Substring(0, MaxExtractedTextLength); + _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); } return await Task.FromResult(text); @@ -113,5 +118,137 @@ private async Task ExtractTextFromTextFileAsync(byte[] fileContent) return string.Empty; } } + + private string ExtractTextFromPdfFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var document = PdfDocument.Open(stream); + var builder = new StringBuilder(); + + foreach (var pageText in document.GetPages().Select(page => page.Text)) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + if (!string.IsNullOrWhiteSpace(pageText)) + { + builder.AppendLine(pageText); + } + } + + var text = builder.ToString(); + if (text.Length > MaxExtractedTextLength) + { + text = text.Substring(0, MaxExtractedTextLength); + } + + return text; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PDF text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private string NormalizeAndLimitText(string text, string fileName) + { + var normalized = NormalizeExtractedText(text); + normalized = RemoveLeadingFileNameArtifact(normalized, fileName); + + if (normalized.Length > MaxExtractedTextLength) + { + normalized = normalized.Substring(0, MaxExtractedTextLength); + _logger.LogDebug("Truncated extracted content to {MaxLength} characters", MaxExtractedTextLength); + } + + return normalized; + } + + private static string NormalizeExtractedText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + var normalized = text + .Replace('\0', ' ') + .Replace("\r\n", "\n") + .Replace('\r', '\n'); + + normalized = LowerToUpperWordBoundaryRegex().Replace(normalized, " "); + normalized = PunctuationToWordBoundaryRegex().Replace(normalized, " "); + normalized = ColonDashSpacingRegex().Replace(normalized, ": - "); + normalized = HyphenSpacingRegex().Replace(normalized, " - "); + normalized = KeywordBoundaryRegex().Replace(normalized, " "); + normalized = MultipleSpacesRegex().Replace(normalized, " "); + normalized = NewlineWhitespaceRegex().Replace(normalized, "\n"); + normalized = MultipleNewlinesRegex().Replace(normalized, "\n"); + + return normalized.Trim(); + } + + private static string RemoveLeadingFileNameArtifact(string text, string fileName) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(fileName)) + { + return text; + } + + var rawStem = Path.GetFileNameWithoutExtension(fileName)?.Trim(); + if (string.IsNullOrWhiteSpace(rawStem)) + { + return text; + } + + var decodedStem = Uri.UnescapeDataString(rawStem); + foreach (var candidate in new[] { rawStem, decodedStem }) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + if (text.StartsWith(candidate, StringComparison.OrdinalIgnoreCase)) + { + var stripped = text.Substring(candidate.Length).TrimStart(' ', '-', ':', '.', '\t'); + if (!string.IsNullOrWhiteSpace(stripped)) + { + return stripped; + } + } + } + + return text; + } + + [GeneratedRegex(@"(?<=[a-z])(?=[A-Z])")] + private static partial Regex LowerToUpperWordBoundaryRegex(); + + [GeneratedRegex(@"(?<=[\.\,\:\;\)])(?=[A-Za-z0-9])")] + private static partial Regex PunctuationToWordBoundaryRegex(); + + [GeneratedRegex(@":-")] + private static partial Regex ColonDashSpacingRegex(); + + [GeneratedRegex(@"(?<=\S)- (?=[A-Za-z])")] + private static partial Regex HyphenSpacingRegex(); + + [GeneratedRegex(@"(?<=[a-z])(?=(project|funding|budget|community|summary|notes|details|planning|outcomes|background|services)\b)", RegexOptions.IgnoreCase)] + private static partial Regex KeywordBoundaryRegex(); + + [GeneratedRegex(@"[ \t]+")] + private static partial Regex MultipleSpacesRegex(); + + [GeneratedRegex(@"\n\s*")] + private static partial Regex NewlineWhitespaceRegex(); + + [GeneratedRegex(@"\n{2,}")] + private static partial Regex MultipleNewlinesRegex(); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs new file mode 100644 index 000000000..44d54844b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/AddressInfoDataProvider.cs @@ -0,0 +1,123 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.ApplicantProfile +{ + /// + /// Provides address information for the applicant profile by querying + /// application addresses linked to the applicant's form submissions. + /// Addresses are resolved via both the ApplicationId and ApplicantId + /// relationships, with duplicates removed. + /// + [ExposeServices(typeof(IApplicantProfileDataProvider))] + public class AddressInfoDataProvider( + ICurrentTenant currentTenant, + IRepository applicationFormSubmissionRepository, + IRepository applicantAddressRepository, + IRepository applicationRepository) + : IApplicantProfileDataProvider, ITransientDependency + { + /// + public string Key => ApplicantProfileKeys.AddressInfo; + + /// + public async Task GetDataAsync(ApplicantProfileInfoRequest request) + { + var dto = new ApplicantAddressInfoDto + { + Addresses = [] + }; + + var subject = request.Subject ?? string.Empty; + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); + + using (currentTenant.Change(request.TenantId)) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var addressesQuery = await applicantAddressRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var matchingSubmissions = submissionsQuery + .Where(s => s.OidcSub == normalizedSubject); + + // Addresses linked via ApplicationId — not editable (owned by an application) + var byApplicationId = + from submission in matchingSubmissions + join address in addressesQuery on submission.ApplicationId equals address.ApplicationId + join application in applicationsQuery on address.ApplicationId equals application.Id + select new { address, address.CreationTime, application.ReferenceNo, IsEditable = false }; + + // Addresses linked via ApplicantId — editable (directly from the applicant) + var byApplicantId = + from submission in matchingSubmissions + join address in addressesQuery on submission.ApplicantId equals address.ApplicantId + join application in applicationsQuery on address.ApplicationId equals application.Id into apps + from application in apps.DefaultIfEmpty() + select new { address, address.CreationTime, ReferenceNo = application != null ? application.ReferenceNo : null, IsEditable = true }; + + var results = await byApplicationId + .Concat(byApplicantId) + .ToListAsync(); + + // Deduplicate by address Id — application-linked (IsEditable = false) takes priority + var deduplicated = results + .GroupBy(r => r.address.Id) + .Select(g => g.OrderBy(r => r.IsEditable).First()) + .ToList(); + + var addressDtos = deduplicated.Select(r => new AddressInfoItemDto + { + Id = r.address.Id, + AddressType = GetAddressTypeName(r.address.AddressType), + Street = r.address.Street ?? string.Empty, + Street2 = r.address.Street2 ?? string.Empty, + Unit = r.address.Unit ?? string.Empty, + City = r.address.City ?? string.Empty, + Province = r.address.Province ?? string.Empty, + PostalCode = r.address.Postal ?? string.Empty, + Country = r.address.Country ?? string.Empty, + IsPrimary = r.address.HasProperty("isPrimary") && r.address.GetProperty("isPrimary"), + IsEditable = r.IsEditable, + ReferenceNo = r.ReferenceNo + }).ToList(); + + // If no address is marked as primary, mark the most recent one as primary + if (addressDtos.Count > 0 && !addressDtos.Any(a => a.IsPrimary)) + { + var mostRecent = deduplicated.OrderByDescending(r => r.CreationTime).First(); + var mostRecentDto = addressDtos.First(a => a.Id == mostRecent.address.Id); + mostRecentDto.IsPrimary = true; + } + + dto.Addresses.AddRange(addressDtos); + } + + return dto; + } + + /// + /// Maps an enum value to a human-readable display name. + /// + private static string GetAddressTypeName(AddressType addressType) + { + return addressType switch + { + AddressType.PhysicalAddress => "Physical", + AddressType.MailingAddress => "Mailing", + AddressType.BusinessAddress => "Business", + _ => addressType.ToString() + }; + } + } +} 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..eba51fa13 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ApplicantProfileContactService.cs @@ -0,0 +1,131 @@ +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, +/// application-level contacts matched by OIDC subject, and applicant agent contacts derived from +/// the submission login token. This service operates independently from the generic +/// and queries repositories directly. +/// +public class ApplicantProfileContactService( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + IRepository applicationFormSubmissionRepository, + IRepository applicationContactRepository, + IRepository applicantAgentRepository, + IRepository applicationRepository) + : 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, + ReferenceNo = null + }).ToListAsync(); + } + + /// + public async Task> GetApplicationContactsBySubjectAsync(string subject) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicationContactsQuery = await applicationContactRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var applicationContacts = await ( + from submission in submissionsQuery + join appContact in applicationContactsQuery on submission.ApplicationId equals appContact.ApplicationId + join application in applicationsQuery on submission.ApplicationId equals application.Id + where submission.OidcSub == subject + 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, + ReferenceNo = application.ReferenceNo + }).ToListAsync(); + + return applicationContacts; + } + + /// + public async Task> GetApplicantAgentContactsBySubjectAsync(string subject) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var agentsQuery = await applicantAgentRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + + var agentContacts = await ( + from submission in submissionsQuery + join agent in agentsQuery on submission.ApplicationId equals agent.ApplicationId + join application in applicationsQuery on submission.ApplicationId equals application.Id + where submission.OidcSub == subject + select new ContactInfoItemDto + { + ContactId = agent.Id, + Name = agent.Name, + Title = agent.Title, + Email = agent.Email, + WorkPhoneNumber = agent.Phone, + WorkPhoneExtension = agent.PhoneExtension, + MobilePhoneNumber = agent.Phone2, + Role = agent.RoleForApplicant, + ContactType = "ApplicantAgent", + IsPrimary = false, + IsEditable = false, + ApplicationId = agent.ApplicationId, + ReferenceNo = application.ReferenceNo + }).ToListAsync(); + + return agentContacts; + } + + 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..e028bb1b2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/ContactInfoDataProvider.cs @@ -0,0 +1,50 @@ +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, application-level contacts, and applicant agent 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 normalizedSubject = request.Subject.Contains('@') + ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant() + : request.Subject.ToUpperInvariant(); + + var applicationContacts = await applicantProfileContactService.GetApplicationContactsBySubjectAsync(normalizedSubject); + dto.Contacts.AddRange(applicationContacts); + + var agentContacts = await applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync(normalizedSubject); + dto.Contacts.AddRange(agentContacts); + } + + 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 60% 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..cc0bc9368 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,14 +1,20 @@ 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 { + /// + /// Provides organization information for the applicant profile. + /// This is a placeholder provider for future implementation. + /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class OrgInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency { + /// public string Key => ApplicantProfileKeys.OrgInfo; + /// public Task GetDataAsync(ApplicantProfileInfoRequest request) { return Task.FromResult(new ApplicantOrgInfoDto()); 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 61% 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..9b7b86e62 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,14 +1,20 @@ 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 { + /// + /// Provides payment information for the applicant profile. + /// This is a placeholder provider for future implementation. + /// [ExposeServices(typeof(IApplicantProfileDataProvider))] public class PaymentInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency { + /// public string Key => ApplicantProfileKeys.PaymentInfo; + /// public Task GetDataAsync(ApplicantProfileInfoRequest request) { return Task.FromResult(new ApplicantPaymentInfoDto()); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs new file mode 100644 index 000000000..60232d7e2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/SubmissionInfoDataProvider.cs @@ -0,0 +1,139 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Integrations; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.ApplicantProfile +{ + /// + /// Provides submission information for the applicant profile by querying + /// application form submissions linked to the applicant's OIDC subject. + /// Resolves actual submission timestamps from CHEFS JSON data and derives + /// the form view URL from the INTAKE_API_BASE dynamic URL setting. + /// + [ExposeServices(typeof(IApplicantProfileDataProvider))] + public class SubmissionInfoDataProvider( + ICurrentTenant currentTenant, + IRepository applicationFormSubmissionRepository, + IRepository applicationRepository, + IRepository applicationStatusRepository, + IEndpointManagementAppService endpointManagementAppService, + ILogger logger) + : IApplicantProfileDataProvider, ITransientDependency + { + /// + public string Key => ApplicantProfileKeys.SubmissionInfo; + + /// + public async Task GetDataAsync(ApplicantProfileInfoRequest request) + { + var dto = new ApplicantSubmissionInfoDto + { + Submissions = [] + }; + + var subject = request.Subject ?? string.Empty; + var normalizedSubject = subject.Contains('@') + ? subject[..subject.IndexOf('@')].ToUpperInvariant() + : subject.ToUpperInvariant(); + + dto.LinkSource = await ResolveFormViewUrlAsync(); + + using (currentTenant.Change(request.TenantId)) + { + var submissionsQuery = await applicationFormSubmissionRepository.GetQueryableAsync(); + var applicationsQuery = await applicationRepository.GetQueryableAsync(); + var statusesQuery = await applicationStatusRepository.GetQueryableAsync(); + + var results = await ( + from submission in submissionsQuery + join application in applicationsQuery on submission.ApplicationId equals application.Id + join status in statusesQuery on application.ApplicationStatusId equals status.Id + where submission.OidcSub == normalizedSubject + select new + { + submission.Id, + LinkId = submission.ChefsSubmissionGuid, + submission.CreationTime, + submission.Submission, + application.ReferenceNo, + application.ProjectName, + Status = status.ExternalStatus + }).ToListAsync(); + + dto.Submissions.AddRange(results.Select(s => new SubmissionInfoItemDto + { + Id = s.Id, + LinkId = s.LinkId, + ReceivedTime = s.CreationTime, + SubmissionTime = ResolveSubmissionTime(s.Submission, s.CreationTime), + ReferenceNo = s.ReferenceNo, + ProjectName = s.ProjectName, + Status = s.Status + })); + } + + return dto; + } + + /// + /// Derives the CHEFS form view URL from the INTAKE_API_BASE dynamic URL setting. + /// e.g. https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1 + /// -> https://chefs-dev.apps.silver.devops.gov.bc.ca/app/form/view?s= + /// + private async Task ResolveFormViewUrlAsync() + { + try + { + var chefsApiBaseUrl = await endpointManagementAppService.GetChefsApiBaseUrlAsync(); + var trimmed = chefsApiBaseUrl.TrimEnd('/'); + const string apiSegment = "/api/v1"; + if (trimmed.EndsWith(apiSegment, StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^apiSegment.Length]; + } + return $"{trimmed}/form/view?s="; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to resolve CHEFS form view URL from INTAKE_API_BASE setting."); + return string.Empty; + } + } + + /// + /// Extracts the submission timestamp from the CHEFS JSON createdAt field. + /// Falls back to the provided value if the field is + /// missing or the JSON cannot be parsed. + /// + private DateTime ResolveSubmissionTime(string submissionJson, DateTime fallback) + { + try + { + if (!string.IsNullOrEmpty(submissionJson)) + { + using var doc = JsonDocument.Parse(submissionJson); + if (doc.RootElement.TryGetProperty("createdAt", out var createdAt) && + createdAt.TryGetDateTime(out var dateTime)) + { + return dateTime; + } + } + } + catch (JsonException ex) + { + logger.LogWarning(ex, "Failed to parse submission JSON for submission time. Falling back to received time."); + } + + return fallback; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.cs deleted file mode 100644 index 693a28994..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/AddressInfoDataProvider.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 AddressInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency - { - public string Key => ApplicantProfileKeys.AddressInfo; - - public Task GetDataAsync(ApplicantProfileInfoRequest request) - { - return Task.FromResult(new ApplicantAddressInfoDto()); - } - } -} 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/ApplicantProfile/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.cs deleted file mode 100644 index 7af7e641f..000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfile/SubmissionInfoDataProvider.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 SubmissionInfoDataProvider : IApplicantProfileDataProvider, ITransientDependency - { - public string Key => ApplicantProfileKeys.SubmissionInfo; - - public Task GetDataAsync(ApplicantProfileInfoRequest request) - { - return Task.FromResult(new ApplicantSubmissionInfoDto()); - } - } -} 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..f8fcc31f7 --- /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/ApplicationLinksAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs index 3ce28a569..f67026cfb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationLinksAppService.cs @@ -5,8 +5,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Unity.GrantManager.Applications; using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; @@ -32,7 +32,7 @@ public class ApplicationLinksAppService : CrudAppService< public IApplicationRepository ApplicationRepository { get; set; } = null!; public IApplicantRepository ApplicantRepository { get; set; } = null!; public IApplicationFormAppService ApplicationFormAppService { get; set; } = null!; - + public ApplicationLinksAppService(IRepository repository) : base(repository) { } public async Task> GetListByApplicationAsync(Guid applicationId) @@ -96,6 +96,58 @@ join applicant in applicantsQuery on application.ApplicantId equals applicant.Id return resultList; } + /// + /// Retrieves a list of application links of the specified type for a given application. + /// + /// Use this method to obtain links associated with a particular application and link type. The + /// returned list is mapped to ApplicationLinksDto for convenient consumption in client code. + /// The unique identifier of the application for which to retrieve links. Must be a valid GUID. + /// The type of application link to retrieve, specified by the enumeration. + /// A task that represents the asynchronous operation. The task result contains a list of + /// objects matching the specified application ID and link type. The list will be empty if no links are found. + public async Task> GetApplicationLinksByType(Guid applicationId, ApplicationLinkType linkType) + { + var applicationLinksQuery = await ApplicationLinksRepository + .GetListAsync(al => al.ApplicationId == applicationId && al.LinkType == linkType); + + return ObjectMapper.Map, List>(applicationLinksQuery); + } + + /// + /// Retrieves a list of child application links associated with the specified applicationId. + /// + /// This method is asynchronous and may involve network or database calls, which could affect + /// performance. Ensure that the applicationId provided is valid to avoid exceptions. + /// The unique identifier of the application for which child applications are being retrieved. This parameter cannot + /// be an empty GUID. + /// A task that represents the asynchronous operation. The task result contains a list of + /// objects representing the child applications. The list will be empty if no child applications are found. + public async Task> GetChildApplications(Guid applicationId) + { + return await GetApplicationLinksByType(applicationId, ApplicationLinkType.Child); + } + + /// + /// Retrieves a dictionary that maps each specified parent application ID to a list of its associated child application IDs. + /// + /// The method fetches application links from the repository and groups them by parent + /// application ID. Ensure that the provided parent application IDs exist in the repository to obtain meaningful + /// results. + /// A list of GUIDs representing the parent application IDs for which to retrieve child application IDs. This + /// parameter cannot be null or empty. + /// A dictionary where each key is a parent application ID and the corresponding value is a list of child + /// application IDs linked to that parent. The dictionary will be empty if no child applications are found. + public async Task>> GetChildApplicationIdsByParentIdsAsync(List parentApplicationIds) + { + var links = await ApplicationLinksRepository + .GetListAsync(al => parentApplicationIds.Contains(al.ApplicationId) + && al.LinkType == ApplicationLinkType.Child); + + return links + .GroupBy(l => l.ApplicationId) + .ToDictionary(g => g.Key, g => g.Select(l => l.LinkedApplicationId).ToList()); + } + public async Task GetLinkedApplicationAsync(Guid currentApplicationId, Guid linkedApplicationId) { var applicationLinksQuery = await ApplicationLinksRepository.GetQueryableAsync(); @@ -154,7 +206,7 @@ join applicant in applicantsQuery on application.ApplicantId equals applicant.Id public async Task GetCurrentApplicationInfoAsync(Guid applicationId) { Logger.LogInformation("GetCurrentApplicationInfoAsync called with applicationId: {ApplicationId}", applicationId); - + try { var applicationsQuery = await ApplicationRepository.GetQueryableAsync(); @@ -162,7 +214,7 @@ public async Task GetCurrentApplicationInfoAsync(Guid a .Include(a => a.ApplicationStatus) .Where(a => a.Id == applicationId) .FirstOrDefaultAsync(); - + if (application == null) { Logger.LogWarning("Application not found with ID: {ApplicationId}", applicationId); @@ -249,13 +301,13 @@ public async Task GetCurrentApplicationInfoAsync(Guid a LinkType = ApplicationLinkType.Related, FormVersion = formVersion }; - + return result; } catch (Exception ex) { Logger.LogError(ex, "Critical error in GetCurrentApplicationInfoAsync for applicationId: {ApplicationId}", applicationId); - + // If all else fails, return a basic structure return new ApplicationLinksInfoDto { @@ -276,16 +328,16 @@ public async Task DeleteWithPairAsync(Guid applicationLinkId) { // Get the link to find the paired record var link = await Repository.GetAsync(applicationLinkId); - + // Find the paired link (reverse direction) var applicationLinksQuery = await ApplicationLinksRepository.GetQueryableAsync(); var pairedLink = await applicationLinksQuery .Where(x => x.ApplicationId == link.LinkedApplicationId && x.LinkedApplicationId == link.ApplicationId) .FirstOrDefaultAsync(); - + // Delete both links await Repository.DeleteAsync(applicationLinkId); - + if (pairedLink != null) { await Repository.DeleteAsync(pairedLink.Id); @@ -295,7 +347,7 @@ public async Task DeleteWithPairAsync(Guid applicationLinkId) public async Task GetApplicationDetailsByReferenceAsync(string referenceNumber) { Logger.LogInformation("GetApplicationDetailsByReferenceAsync called with referenceNumber: {ReferenceNumber}", referenceNumber); - + try { var applicationsQuery = await ApplicationRepository.GetQueryableAsync(); @@ -303,7 +355,7 @@ public async Task GetApplicationDetailsByReferenceAsync .Include(a => a.ApplicationStatus) .Where(a => a.ReferenceNo == referenceNumber) .FirstOrDefaultAsync(); - + if (application == null) { Logger.LogWarning("Application not found with ReferenceNumber: {ReferenceNumber}", referenceNumber); @@ -375,7 +427,7 @@ public async Task GetApplicationDetailsByReferenceAsync catch (Exception ex) { Logger.LogError(ex, "Critical error in GetApplicationDetailsByReferenceAsync for referenceNumber: {ReferenceNumber}", referenceNumber); - + return new ApplicationLinksInfoDto { Id = Guid.Empty, @@ -394,46 +446,46 @@ public async Task GetApplicationDetailsByReferenceAsync public async Task UpdateLinkTypeAsync(Guid applicationLinkId, ApplicationLinkType newLinkType) { Logger.LogInformation("UpdateLinkTypeAsync called with linkId: {LinkId}, newLinkType: {LinkType}", applicationLinkId, newLinkType); - + // Get the existing link var link = await Repository.GetAsync(applicationLinkId); - + if (link != null) { // Update the link type link.LinkType = newLinkType; await Repository.UpdateAsync(link); - + Logger.LogInformation("Successfully updated link type for linkId: {LinkId}", applicationLinkId); } else { Logger.LogWarning("Link not found with ID: {LinkId}", applicationLinkId); } - + } public async Task ValidateApplicationLinksAsync( - Guid currentApplicationId, + Guid currentApplicationId, List proposedLinks) { var result = new ApplicationLinkValidationResult(); - + // Skip validation for empty or Related-only links var hierarchicalLinks = proposedLinks.Where(l => l.LinkType != ApplicationLinkType.Related).ToList(); if (hierarchicalLinks.Count == 0) { return result; } - + // Validate current app constraints var currentAppError = ValidateCurrentApplicationConstraints(hierarchicalLinks); - + // Process each proposed link foreach (var proposedLink in hierarchicalLinks) { var errorMessage = await ValidateLinkBasedOnType(currentApplicationId, proposedLink, currentAppError, hierarchicalLinks); - + if (!string.IsNullOrEmpty(errorMessage)) { result.ValidationErrors[proposedLink.ReferenceNumber] = true; @@ -444,31 +496,31 @@ public async Task ValidateApplicationLinksAsync result.ValidationErrors[proposedLink.ReferenceNumber] = false; } } - + return result; } - + private static string ValidateCurrentApplicationConstraints(List proposedLinks) { var parentCount = proposedLinks.Count(l => l.LinkType == ApplicationLinkType.Parent); - var hasParent = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent); - var hasChild = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Child); + var hasParent = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent); + var hasChild = proposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Child); if (parentCount > 1) { return ERROR_MULTIPLE_PARENTS; } - + if (hasParent && hasChild) { return ERROR_PARENT_WITH_CHILDREN; } - + return string.Empty; } - + private async Task ValidateLinkBasedOnType( - Guid currentApplicationId, + Guid currentApplicationId, ApplicationLinkValidationRequest proposedLink, string currentAppError, List allProposedLinks) @@ -481,25 +533,25 @@ private async Task ValidateLinkBasedOnType( { return currentAppError; } - + // Then check if the proposed parent is already a child of another app return await ValidateTargetCannotBeParentIfAlreadyChild(currentApplicationId, proposedLink); - + case ApplicationLinkType.Child: // Check if current app is trying to be both parent and child if (!string.IsNullOrEmpty(currentAppError) && allProposedLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent)) { return ERROR_CURRENT_APP_IS_CHILD; } - + // Check target app conflicts return await ValidateTargetCanAcceptChildLink(currentApplicationId, proposedLink); - + default: return string.Empty; } } - + private async Task ValidateTargetCannotBeParentIfAlreadyChild(Guid currentApplicationId, ApplicationLinkValidationRequest proposedLink) { var targetLinks = await GetListByApplicationAsync(proposedLink.TargetApplicationId); @@ -513,29 +565,29 @@ private async Task ValidateTargetCannotBeParentIfAlreadyChild(Guid curre { return ERROR_TARGET_CHILD_CANNOT_BE_PARENT; } - + return string.Empty; } - + private async Task ValidateTargetCanAcceptChildLink(Guid currentApplicationId, ApplicationLinkValidationRequest proposedLink) { var targetLinks = await GetListByApplicationAsync(proposedLink.TargetApplicationId); // Exclude reverse links and self-references - var targetExternalLinks = targetLinks.Where(l => - l.ApplicationId != currentApplicationId && + var targetExternalLinks = targetLinks.Where(l => + l.ApplicationId != currentApplicationId && l.ApplicationId != proposedLink.TargetApplicationId).ToList(); - + if (targetExternalLinks.Exists(l => l.LinkType == ApplicationLinkType.Parent)) { return ERROR_TARGET_ALREADY_HAS_PARENT; } - + if (targetExternalLinks.Exists(l => l.LinkType == ApplicationLinkType.Child)) { return ERROR_TARGET_IS_PARENT_TO_OTHERS; } - + return string.Empty; } } \ No newline at end of file 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..159f77336 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,6 @@ using Unity.GrantManager.Payments; using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp; using Volo.Abp.Application.Dtos; @@ -40,10 +39,10 @@ namespace Unity.GrantManager.GrantApplications; public class GrantApplicationAppService( IApplicationManager applicationManager, IApplicationRepository applicationRepository, - IApplicationStatusRepository applicationStatusRepository, + IApplicationStatusRepository applicationStatusRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicantRepository applicantRepository, - IApplicationFormRepository applicationFormRepository, + IApplicationFormRepository applicationFormRepository, IApplicantAgentRepository applicantAgentRepository, IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, @@ -52,7 +51,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, @@ -63,22 +62,17 @@ public async Task> GetListAsync(GrantApplica var applicationIds = applications.Select(a => a.Id).ToList(); + // 2️ Fetch payment rollup batch if feature enabled bool paymentsFeatureEnabled = await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); - List paymentRequests = []; + Dictionary paymentRollupBatch = []; if (paymentsFeatureEnabled && applicationIds.Count > 0) { - paymentRequests = await paymentRequestService.GetListByApplicationIdsAsync(applicationIds); + paymentRollupBatch = await paymentRequestService.GetApplicationPaymentRollupBatchAsync(applicationIds); } - // 2️⃣ Pre-aggregate payment amounts for O(1) lookup - var paymentRequestsByApplication = paymentRequests - .Where(pr => pr.Status == PaymentRequestStatus.Submitted) - .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); @@ -101,19 +95,20 @@ public async Task> GetListAsync(GrantApplica appDto.ContactCellPhone = app.ApplicantAgent?.Phone2; appDto.ApplicationLinks = ObjectMapper.Map, List>(app.ApplicationLinks?.ToList() ?? []); - if (paymentsFeatureEnabled && paymentRequestsByApplication.Count > 0) + if (paymentsFeatureEnabled && paymentRollupBatch.Count > 0) { + paymentRollupBatch.TryGetValue(app.Id, out var rollup); appDto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = paymentRequestsByApplication.GetValueOrDefault(app.Id) + TotalPaid = rollup?.TotalPaid ?? 0 }; } return appDto; }).ToList(); - // 4️⃣ Get total count using same filters + // 4️ Get total count using same filters var totalCount = await applicationRepository.GetCountAsync( input.SubmittedFromDate, input.SubmittedToDate @@ -215,7 +210,7 @@ public async Task GetAsync(Guid id) public async Task GetApplicationFormAsync(Guid applicationFormId) { return await (await applicationFormRepository.GetQueryableAsync()).FirstOrDefaultAsync(s => s.Id == applicationFormId); - } + } [Authorize(UnitySelector.Review.AssessmentResults.Update.Default)] public async Task UpdateAssessmentResultsAsync(Guid id, CreateUpdateAssessmentResultsDto input) @@ -671,7 +666,7 @@ public async Task UpdateSupplierNumberAsync(Guid applicationId, string? supplier } return await applicantAgentRepository.UpdateAsync(applicantAgent); - } + } [Authorize(UnitySelector.Applicant.UpdatePolicy)] public async Task UpdateMergedApplicantAsync(Guid applicationId, CreateUpdateApplicantInfoDto input) @@ -816,7 +811,7 @@ public async Task UpdateApplicationStatus(Guid[] applicationIds, Guid statusId) Debug.WriteLine(ex.ToString()); } } - } + } public async Task> GetApplicationListAsync(List applicationIds) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs index 858bf9640..1cdcd6b49 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs @@ -54,7 +54,7 @@ public async Task ProcessFormSubmissionAsync(ApplicationForm applicationFo intakeMap.SubmissionId = formSubmission.submission.id; intakeMap.SubmissionDate = formSubmission.submission.updatedAt; intakeMap.ConfirmationId = formSubmission.submission.confirmationId; - using var uow = _unitOfWorkManager.Begin(); + using var uow = _unitOfWorkManager.Begin(isTransactional: true);//transaction needed for sequence number generation (SequenceRepository) to ensure atomicity and consistency var application = await CreateNewApplicationAsync(intakeMap, applicationForm); await _intakeFormSubmissionMapper.SaveChefsFiles(formSubmission, application.Id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj index ecb9a894a..8ec3e53bc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj @@ -30,8 +30,9 @@ - + + 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.Domain/Applications/ApplicationForm.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationForm.cs index b60d8264f..a7324e867 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationForm.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationForm.cs @@ -33,6 +33,7 @@ public class ApplicationForm : FullAuditedAggregateRoot, IMultiTenant public Guid? ParentFormId { get; set; } public bool RenderFormIoToHtml { get; set; } = false; public bool IsDirectApproval { get; set; } = false; + [MaxLength(100)] public string? Prefix { get; set; } public SuffixConfigType? SuffixType { get; set; } public static List<(SuffixConfigType SuffixType, string DisplayName)> GetAvailableSuffixTypes() diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ISequenceRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ISequenceRepository.cs index 13bca3efb..d03af8505 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ISequenceRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ISequenceRepository.cs @@ -7,7 +7,9 @@ public interface ISequenceRepository : IRepository { /// /// Gets the next sequential number for a given prefix within the current tenant. - /// Uses tenant-specific PostgreSQL sequences to ensure uniqueness. + /// Uses a table-based atomic counter (unity_sequence_counters) that participates + /// in the ambient EF Core transaction, ensuring gapless IDs on rollback. + /// Must be called within an active transaction — throws InvalidOperationException otherwise. /// /// The prefix for the sequence (e.g., "CGG-") /// The next sequential number for this tenant+prefix combination diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226020846_Add_UnitySequenceCounters_Table.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226020846_Add_UnitySequenceCounters_Table.Designer.cs new file mode 100644 index 000000000..9bbc4bfc9 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226020846_Add_UnitySequenceCounters_Table.Designer.cs @@ -0,0 +1,4571 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260226020246_Add_UnitySequenceCounters_Table")] + partial class Add_UnitySequenceCounters_Table + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationSize") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasColumnType("text"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("RenderFormIoToHtml") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("RenderedHTML") + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226020846_Add_UnitySequenceCounters_Table.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226020846_Add_UnitySequenceCounters_Table.cs new file mode 100644 index 000000000..78ac7c5b0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226020846_Add_UnitySequenceCounters_Table.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class Add_UnitySequenceCounters_Table : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // PRIMARY KEY on (tenant_id, prefix) enforces uniqueness and provides the index + // required by the ON CONFLICT clause in the upsert counter query. + migrationBuilder.Sql(@" + CREATE TABLE IF NOT EXISTS ""unity_sequence_counters"" ( + ""tenant_id"" UUID NOT NULL, + ""prefix"" TEXT NOT NULL, + ""current_value"" BIGINT NOT NULL DEFAULT 0, + CONSTRAINT ""PK_unity_sequence_counters"" PRIMARY KEY (""tenant_id"", ""prefix"") + ); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@"DROP TABLE IF EXISTS ""unity_sequence_counters"";"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226021054_Renumber_UnityApplicationId_And_Seed_Counters.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226021054_Renumber_UnityApplicationId_And_Seed_Counters.Designer.cs new file mode 100644 index 000000000..036802c5f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226021054_Renumber_UnityApplicationId_And_Seed_Counters.Designer.cs @@ -0,0 +1,4571 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260226020620_Renumber_UnityApplicationId_And_Seed_Counters")] + partial class Renumber_UnityApplicationId_And_Seed_Counters + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationSize") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasColumnType("text"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("RenderFormIoToHtml") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("RenderedHTML") + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226021054_Renumber_UnityApplicationId_And_Seed_Counters.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226021054_Renumber_UnityApplicationId_And_Seed_Counters.cs new file mode 100644 index 000000000..487d80375 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260226021054_Renumber_UnityApplicationId_And_Seed_Counters.cs @@ -0,0 +1,109 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class Renumber_UnityApplicationId_And_Seed_Counters : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // A: Renumber existing sequential UnityApplicationIds to fill historical gaps. + // + // Partitioned by (TenantId, Prefix) — tenants sharing a schema get separate, + // independent sequences. Prefix comes from the ApplicationForm definition + // (not inferred from the ID string) to avoid regex metacharacter issues. + // Only SuffixType = 1 (SequentialNumber) forms are touched. + // Stable ordering: original suffix number → CreationTime → Id. + // Malformed IDs (non-numeric suffix) are left untouched. + migrationBuilder.Sql(@" + WITH base AS ( + SELECT + a.""Id"", + COALESCE(a.""TenantId"", '00000000-0000-0000-0000-000000000000'::UUID) AS tenant_id, + af.""Prefix"", + a.""CreationTime"", + SUBSTRING(a.""UnityApplicationId"" FROM CHAR_LENGTH(af.""Prefix"") + 1) AS suffix + FROM ""Applications"" a + JOIN ""ApplicationForms"" af ON a.""ApplicationFormId"" = af.""Id"" + WHERE af.""SuffixType"" = 1 + AND af.""Prefix"" IS NOT NULL + AND af.""Prefix"" <> '' + AND a.""UnityApplicationId"" IS NOT NULL + AND LEFT(a.""UnityApplicationId"", CHAR_LENGTH(af.""Prefix"")) = af.""Prefix"" + ), + valid AS ( + SELECT + ""Id"", + tenant_id, + ""Prefix"", + ""CreationTime"", + suffix::BIGINT AS old_seq + FROM base + WHERE suffix ~ '^[0-9]+$' + ), + ranked AS ( + SELECT + ""Id"", + ""Prefix"", + ROW_NUMBER() OVER ( + PARTITION BY tenant_id, ""Prefix"" + ORDER BY old_seq, ""CreationTime"", ""Id"" + ) AS new_seq + FROM valid + ) + UPDATE ""Applications"" a + SET ""UnityApplicationId"" = + r.""Prefix"" || LPAD(r.new_seq::TEXT, GREATEST(5, LENGTH(r.new_seq::TEXT)), '0') + FROM ranked r + WHERE a.""Id"" = r.""Id""; + "); + + + // B: Seed unity_sequence_counters from the post-renumber maximum. + // + // LEFT JOIN from ApplicationForms so that every (tenant, prefix) combination that + // is configured for sequential numbering gets a counter row — even forms that have + // zero applications yet. Those are seeded with current_value = 0, so the first + // real upsert increments to 1 without a gap. + migrationBuilder.Sql(@" + WITH parsed AS ( + SELECT + COALESCE(a.""TenantId"", af.""TenantId"", '00000000-0000-0000-0000-000000000000'::UUID) AS tenant_id, + af.""Prefix"" AS prefix, + CASE + WHEN a.""UnityApplicationId"" IS NOT NULL + AND LEFT(a.""UnityApplicationId"", CHAR_LENGTH(af.""Prefix"")) = af.""Prefix"" + AND SUBSTRING(a.""UnityApplicationId"" FROM CHAR_LENGTH(af.""Prefix"") + 1) ~ '^[0-9]+$' + THEN CAST(SUBSTRING(a.""UnityApplicationId"" FROM CHAR_LENGTH(af.""Prefix"") + 1) AS BIGINT) + ELSE NULL + END AS seq + FROM ""ApplicationForms"" af + LEFT JOIN ""Applications"" a ON a.""ApplicationFormId"" = af.""Id"" + WHERE af.""SuffixType"" = 1 + AND af.""Prefix"" IS NOT NULL + AND af.""Prefix"" <> '' + ) + INSERT INTO ""unity_sequence_counters"" (""tenant_id"", ""prefix"", ""current_value"") + SELECT tenant_id, prefix, COALESCE(MAX(seq), 0) + FROM parsed + GROUP BY tenant_id, prefix + ON CONFLICT (""tenant_id"", ""prefix"") DO UPDATE + SET ""current_value"" = GREATEST( + ""unity_sequence_counters"".""current_value"", + EXCLUDED.""current_value"" + ); + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Counter rows are removed; un-renumbering IDs is not supported. + // Full rollback requires restoring from a pre-migration backup. + migrationBuilder.Sql(@"DELETE FROM ""unity_sequence_counters"";"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs index fd2aa8373..be337f9d1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs @@ -4280,7 +4280,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => { b.HasOne("Unity.GrantManager.Applications.Application", null) - .WithMany() + .WithMany("ApplicationLinks") .HasForeignKey("ApplicationId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -4539,6 +4539,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ApplicationAssignments"); + b.Navigation("ApplicationLinks"); + b.Navigation("ApplicationTags"); b.Navigation("Assessments"); 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.EntityFrameworkCore/Repositories/SequenceRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/SequenceRepository.cs index ae6e6d3ea..34b740a91 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/SequenceRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/SequenceRepository.cs @@ -1,83 +1,111 @@ using System; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; using Npgsql; using Unity.GrantManager.Applications; using Unity.GrantManager.EntityFrameworkCore; using Volo.Abp.Domain.Repositories.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore; -using Volo.Abp.Uow; namespace Unity.GrantManager.Repositories; -public class SequenceRepository(IDbContextProvider dbContextProvider, IUnitOfWorkManager unitOfWorkManager) : EfCoreRepository(dbContextProvider), ISequenceRepository +public class SequenceRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), ISequenceRepository { - public async Task GetNextSequenceNumberAsync(string prefix) { var tenantId = CurrentTenant.Id ?? Guid.Empty; - + + var dbContext = await GetDbContextAsync(); + + var currentTransaction = dbContext.Database.CurrentTransaction + ?? throw new InvalidOperationException( + $"GetNextSequenceNumberAsync requires an active ambient transaction. " + + $"TenantId: {tenantId}, Prefix: {prefix}"); + + var npgsqlTransaction = currentTransaction.GetDbTransaction() as NpgsqlTransaction + ?? throw new InvalidOperationException( + "The current database transaction is not an NpgsqlTransaction."); + + var schema = dbContext.Model.GetDefaultSchema(); + if (string.IsNullOrEmpty(schema)) + { + // Use 'public' as default for PostgreSQL if no schema is configured + schema = "public"; + } + var commandBuilder = new NpgsqlCommandBuilder(); + var safeSchema = commandBuilder.QuoteIdentifier(schema); + + // Schema is sanitized via NpgsqlCommandBuilder.QuoteIdentifier; table name is a known + // lowercase constant that requires no quoting. All user-supplied values (tenantId, prefix) + // are passed as parameters, not interpolated into the SQL string. + const string sql = @" + INSERT INTO {0}.unity_sequence_counters (tenant_id, prefix, current_value) + VALUES (@tenantId, @prefix, 1) + ON CONFLICT (tenant_id, prefix) DO UPDATE + SET current_value = unity_sequence_counters.current_value + 1 + RETURNING current_value;"; + + var sqlWithSchema = string.Format(sql, safeSchema); + + // Use a SAVEPOINT so that a DB error during the upsert rolls back only the counter + // statement, leaving the outer transaction alive. This preserves graceful degradation: + // the caller catches the re-thrown exception and continues with UnityApplicationId = null. + const string savepointName = "unity_seq_counter"; + await npgsqlTransaction.SaveAsync(savepointName); + try { - // Create a new isolated unit of work to prevent transaction pollution - using var uow = unitOfWorkManager.Begin( - requiresNew: true, - isTransactional: true - ); - - var dbContext = await GetDbContextAsync(); var connection = dbContext.Database.GetDbConnection(); - - var schema = dbContext.Model.GetDefaultSchema(); - - if (string.IsNullOrEmpty(schema)) - { - // Use 'public' as default for PostgreSQL if no schema is configured - schema = "public"; - } - - var commandBuilder = new NpgsqlCommandBuilder(); - var safeSchema = commandBuilder.QuoteIdentifier(schema); - - // Build SQL command with properly quoted schema identifier to prevent SQL injection - var sqlCommand = string.Format("SELECT {0}.get_next_sequence_number(@tenantId, @prefix);", safeSchema); - using var command = connection.CreateCommand(); - // Schema is sanitized via QuoteIdentifier, parameters are properly parameterized -#pragma warning disable S2077 // SQL queries should not be dynamically formatted - command.CommandText = sqlCommand; +#pragma warning disable S2077 // Schema identifier is sanitized via NpgsqlCommandBuilder.QuoteIdentifier; all user values are parameterized + command.CommandText = sqlWithSchema; #pragma warning restore S2077 + command.Transaction = npgsqlTransaction; command.Parameters.Add(new NpgsqlParameter("tenantId", tenantId)); command.Parameters.Add(new NpgsqlParameter("prefix", prefix)); - - if (connection.State != System.Data.ConnectionState.Open) - { - await connection.OpenAsync(); - } - + var result = await command.ExecuteScalarAsync(); - var sequenceNumber = (long)(result ?? 1L); - + var sequenceNumber = Convert.ToInt64(result ?? 1L); + + await npgsqlTransaction.ReleaseAsync(savepointName); + Logger.LogInformation( "Successfully generated sequence number {SequenceNumber} for prefix {Prefix} in tenant {TenantId}", sequenceNumber, prefix, tenantId); - - await uow.CompleteAsync(); + return sequenceNumber; } catch (Exception ex) { - Logger.LogError(ex, - "Failed to execute get_next_sequence_number function. " + + try + { + // ROLLBACK TO SAVEPOINT recovers the outer transaction from the aborted state. + // RELEASE cleans up the savepoint so it can be reused if this method is called + // again within the same transaction. + await npgsqlTransaction.RollbackAsync(savepointName); + await npgsqlTransaction.ReleaseAsync(savepointName); + } + catch (Exception rollbackEx) + { + Logger.LogError(rollbackEx, + "Failed to rollback to savepoint '{SavepointName}'. " + + "The outer transaction may be in an invalid state.", + savepointName); + } + + Logger.LogError(ex, + "Failed to execute sequence counter upsert. " + "TenantId: {TenantId}, Prefix: {Prefix}. " + - "This error is isolated and will not affect the main transaction.", + "Outer transaction remains alive via savepoint rollback.", tenantId, prefix); - // throw to be handled by the caller's graceful degradation + // Re-throw so the caller's graceful degradation (null UnityApplicationId) kicks in. throw new InvalidOperationException( - $"Failed to generate sequence number for tenant '{tenantId}' with prefix '{prefix}'. ", + $"Failed to generate sequence number for tenant '{tenantId}' with prefix '{prefix}'.", ex); } } -} \ No newline at end of file +} 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.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.cshtml index 9995d2c4c..05cf01bc3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.cshtml @@ -18,7 +18,6 @@ @section scripts { - } 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..9754b81a2 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; @@ -90,7 +92,24 @@ $('#search, .custom-filter-input').val(''); dt.columns().search(''); dt.search(''); - dt.order(initialSortOrder).draw(); + dt.order(initialSortOrder); + + // Reset date range filters + UIElements.quickDateRange.val(defaultQuickDateRange); + toggleCustomDateInputs(defaultQuickDateRange === 'custom'); + + const range = getDateRange(defaultQuickDateRange); + setDateRangeLocalStorage(defaultQuickDateRange, range); + + if (range) { + UIElements.submittedFromInput.val(range.fromDate); + UIElements.submittedToInput.val(range.toDate); + grantTableFilters.submittedFromDate = range.fromDate; + grantTableFilters.submittedToDate = range.toDate; + } + + // Reload table data with updated filters + dt.ajax.reload(null, false); // Close the dropdown dt.buttons('.grp-savedStates') @@ -109,7 +128,7 @@ } ]; -const listColumns = getColumns(); + const listColumns = getColumns(); const defaultVisibleColumns = ['select', 'applicantName', 'category', @@ -132,10 +151,12 @@ const listColumns = getColumns(); }; const UIElements = { + searchField: $('#search'), + quickDateRange: $('#quickDateRange'), inputFilter: $('.date-input-filter'), submittedToInput: $('#submittedToDate'), submittedFromInput: $('#submittedFromDate'), - }; + }; let responseCallback = function (result) { return { @@ -164,50 +185,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; + + // Set the dropdown value + UIElements.quickDateRange.val(savedRange); - // Check if localStorage has values and use them + // 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); + UIElements.quickDateRange.on('change', handleQuickDateRangeChange); } function validateDate(dateValue, element) { @@ -215,34 +227,83 @@ const listColumns = getColumns(); const selectedDate = new Date(dateValue); const today = new Date(); today.setHours(0, 0, 0, 0); - + const minDate = element.attr('min') ? new Date(element.attr('min')) : null; const maxDate = element.attr('max') ? new Date(element.attr('max')) : null; - + if (selectedDate > today) { element.addClass('input-validation-error'); abp.notify.error('The date cannot be in the future', 'Invalid Date'); return false; } - + if (minDate && selectedDate < minDate) { element.addClass('input-validation-error'); abp.notify.error('The date cannot be before the minimum allowed date', 'Invalid Date'); return false; } - + if (maxDate && selectedDate > maxDate) { element.addClass('input-validation-error'); abp.notify.error('The date cannot be after the maximum allowed date', 'Invalid Date'); return false; } - + element.removeClass('input-validation-error'); return true; } 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 +316,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 + UIElements.quickDateRange.val('custom'); + localStorage.setItem('GrantApplications_QuickRange', 'custom'); + const dtInstance = $('#GrantApplicationsTable').DataTable(); localStorage.setItem("GrantApplications_FromDate", grantTableFilters.submittedFromDate); @@ -262,13 +328,57 @@ const listColumns = getColumns(); dtInstance.ajax.reload(null, true); } - + + function setDateRangeLocalStorage(quickDateRange, fromToRange) { + localStorage.setItem('GrantApplications_QuickRange', quickDateRange || defaultQuickDateRange); + if (fromToRange) { + if (fromToRange.fromDate && fromToRange.toDate) { + localStorage.setItem('GrantApplications_FromDate', fromToRange.fromDate); + localStorage.setItem('GrantApplications_ToDate', fromToRange.toDate); + } else { + // For "All time", clear the date filters + localStorage.removeItem('GrantApplications_FromDate'); + localStorage.removeItem('GrantApplications_ToDate'); + } + } + } + + 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); + setDateRangeLocalStorage(selectedRange, range); + 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; + + // Reload the table with new filters + const dtInstance = $('#GrantApplicationsTable').DataTable(); + dtInstance.ajax.reload(null, true); + } + } + function initializeDataTableAndEvents() { dataTable = initializeDataTable({ dt, defaultVisibleColumns, listColumns, - maxRowsPerPage: 10, + maxRowsPerPage: 10, defaultSortColumn: { name: 'submissionDate', dir: 'desc' @@ -279,7 +389,7 @@ const listColumns = getColumns(); submittedFromDate: grantTableFilters.submittedFromDate, submittedToDate: grantTableFilters.submittedToDate }; - }, + }, responseCallback, actionButtons, serverSideEnabled: false, @@ -287,7 +397,31 @@ const listColumns = getColumns(); reorderEnabled: true, languageSetValues, dataTableName: 'GrantApplicationsTable', - dynamicButtonContainerId: 'dynamicButtonContainerId' + dynamicButtonContainerId: 'dynamicButtonContainerId', + onStateSaveParams: function (settings, data) { + data.customFilters = { + searchValue: UIElements.searchField.val() || '', + quickDateRange: UIElements.quickDateRange.val(), + submittedFromDate: UIElements.submittedFromInput.val(), + submittedToDate: UIElements.submittedToInput.val() + }; + }, + onStateLoadParams: function (settings, data) { + if (data?.customFilters) { + // If there is any date change, this will refresh post load + // to ensure the correct data is shown based on the saved filters. + data.refreshTableWithDates = + data.customFilters.quickDateRange !== UIElements.quickDateRange.val() + || data.customFilters.submittedFromDate !== UIElements.submittedFromInput.val() + || data.customFilters.submittedToDate !== UIElements.submittedToInput.val(); + restoreCustomFilters(data.customFilters); + } + }, + onStateLoaded: function (dtApi, data) { + if (data?.refreshTableWithDates) { + dtApi.ajax.reload(null, false); + } + } }); dataTable.on('search.dt', () => handleSearch()); @@ -327,6 +461,24 @@ const listColumns = getColumns(); $('.grp-savedStates').text('Save View'); $('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view'); + // Helper function to restore custom filters + function restoreCustomFilters(filters) { + UIElements.searchField.val(filters.searchValue || ''); + + UIElements.quickDateRange.val(filters.quickDateRange || defaultQuickDateRange); + toggleCustomDateInputs(filters.quickDateRange === 'custom'); + + UIElements.submittedFromInput.val(filters.submittedFromDate || ''); + UIElements.submittedToInput.val(filters.submittedToDate || ''); + + grantTableFilters.submittedFromDate = filters.submittedFromDate || null; + grantTableFilters.submittedToDate = filters.submittedToDate || null; + + // Update localStorage to stay in sync + setDateRangeLocalStorage(filters?.quickDateRange, { fromDate: filters.submittedFromDate, toDate: filters.submittedToDate }); + } + + function selectApplication(type, indexes, action) { if (type === 'row') { let data = dataTable.row(indexes).data(); @@ -336,7 +488,6 @@ const listColumns = getColumns(); function handleSearch() { let filter = $('.dt-search input').val(); - console.info(filter); } function getColumns() { @@ -361,7 +512,7 @@ const listColumns = getColumns(); getOrganizationNumberColumn(columnIndex++), getOrgBookStatusColumn(columnIndex++), getProjectStartDateColumn(columnIndex++), - getProjectEndDateColumn(columnIndex++), + getProjectEndDateColumn(columnIndex++), getProjectedFundingTotalColumn(columnIndex++), getTotalProjectBudgetPercentageColumn(columnIndex++), getTotalPaidAmountColumn(columnIndex++), @@ -431,7 +582,7 @@ const listColumns = getColumns(); data: 'referenceNo', name: 'referenceNo', className: 'data-table-header text-nowrap', - render: function (data, type, row) { + render: function (data, type, row) { return `${data}`; }, index: columnIndex @@ -456,8 +607,7 @@ const listColumns = getColumns(); className: 'data-table-header', index: columnIndex, render: function (data, type) { - const formattedDate = DateUtils.formatUtcDateToLocal(data, type); - return formattedDate ? String(formattedDate) : ''; + return DateUtils.formatUtcDateToLocal(data, type); } }; } @@ -874,8 +1024,8 @@ const listColumns = getColumns(); render: function (data) { let tagNames = data - .filter(x => x?.tag?.name) - .map(x => x.tag.name); + .filter(x => x?.tag?.name) + .map(x => x.tag.name); return tagNames.join(', ') ?? ''; }, index: columnIndex 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..1134892b1 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..5ccbfa7af 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,17 @@ margin-bottom: -10px; padding-bottom: 0px !important; } + +.custom-date-range-container-div { + display: inline-block; + padding: 0px; + margin: 0px; +} + +.quick-date-input { + font-size: var(--bc-font-size); + color: var(--bc-colors-grey-text-500); + border-radius: var(--bc-layout-margin-small) !important; + border: 2px solid var(--bc-colors-blue-primary); + text-overflow: ellipsis; +} 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..db7b30912 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,6 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Payments; -using Unity.Payments.Enums; using Unity.Payments.PaymentRequests; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; @@ -57,14 +56,10 @@ public async Task InvokeAsync(Guid applicantId) var applicationIds = applications.Select(app => app.Id).ToList(); var paymentsFeatureEnabled = await _featureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature); - Dictionary paymentRequestsByApplication = []; + Dictionary paymentRollupBatch = []; if (paymentsFeatureEnabled && applicationIds.Count > 0) { - var paymentRequests = await _paymentRequestService.GetListByApplicationIdsAsync(applicationIds); - paymentRequestsByApplication = paymentRequests - .Where(pr => pr.Status == PaymentRequestStatus.Submitted) - .GroupBy(pr => pr.CorrelationId) - .ToDictionary(g => g.Key, g => g.Sum(pr => pr.Amount)); + paymentRollupBatch = await _paymentRequestService.GetApplicationPaymentRollupBatchAsync(applicationIds); } // Map to DTOs (similar to GrantApplicationAppService.GetListAsync) @@ -131,13 +126,13 @@ public async Task InvokeAsync(Guid applicantId) } dto.Assignees = assigneeDtos; - if (paymentsFeatureEnabled && paymentRequestsByApplication.Count > 0) + if (paymentsFeatureEnabled && paymentRollupBatch.Count > 0) { - paymentRequestsByApplication.TryGetValue(app.Id, out var totalPaid); + paymentRollupBatch.TryGetValue(app.Id, out var paymentRollup); dto.PaymentInfo = new PaymentInfoDto { ApprovedAmount = app.ApprovedAmount, - TotalPaid = totalPaid + TotalPaid = paymentRollup?.TotalPaid ?? 0 }; } @@ -174,7 +169,6 @@ public class ApplicantSubmissionsScriptBundleContributor : BundleContributor { public override void ConfigureBundle(BundleConfigurationContext context) { - context.Files.AddIfNotContains("/js/DateUtils.js"); context.Files.AddIfNotContains("/Views/Shared/Components/ApplicantSubmissions/Default.js"); } } 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/ApplicantSubmissions/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js index 198501272..525bda320 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js @@ -264,8 +264,7 @@ $(function () { className: 'data-table-header', index: columnIndex, render: function (data, type) { - const formattedDate = DateUtils.formatUtcDateToLocal(data, type); - return formattedDate ? String(formattedDate) : ''; + return DateUtils.formatUtcDateToLocal(data, type); } }; } 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/ApplicationFormConfigWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.cshtml index b8b339ebf..a37d8d733 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.cshtml @@ -57,11 +57,12 @@
-
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/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index e07ce701b..4e9bb37e7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -113,10 +113,10 @@ private static void ResolveAiAnswer(Dictionary aiAnswers, Q { question.IsHumanConfirmed = false; // Mark as AI generated - // Handle enhanced AI response format with answer, citation, and confidence + // Handle AI response format with answer, rationale, and confidence. if (aiAnswerValue.ValueKind == JsonValueKind.Object) { - // New format with citations and confidence scores + // New format with rationale and confidence if (aiAnswerValue.TryGetProperty("answer", out var answerProp)) { var rawAnswer = answerProp.ToString(); @@ -131,14 +131,14 @@ private static void ResolveAiAnswer(Dictionary aiAnswers, Q question.Answer = rawAnswer; } } - if (aiAnswerValue.TryGetProperty("citation", out var citationProp)) + if (aiAnswerValue.TryGetProperty("rationale", out var rationaleProp)) { - question.AICitation = citationProp.ToString(); + question.AICitation = rationaleProp.ToString(); } if (aiAnswerValue.TryGetProperty("confidence", out var confidenceProp) && - confidenceProp.TryGetInt32(out var confidenceScore)) + confidenceProp.TryGetInt32(out var confidence)) { - question.AIConfidence = confidenceScore; + question.AIConfidence = Math.Clamp(confidence, 0, 100); } } else diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index 0839196de..5e9142ac5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -59,8 +59,8 @@ } @if (question.AIConfidence.HasValue && question.AIConfidence.Value < 85) { - - @question.AIConfidence.Value.ToString("F1")% + + @question.AIConfidence.Value.ToString("F0")% } @@ -229,5 +229,3 @@ else
} - - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs index 66ab7c583..551042768 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs @@ -40,6 +40,7 @@ public async Task InvokeAsync(string? formVersionId, strin var formVersion = await applicationFormVersionAppService.GetByChefsFormVersionId(model.ChefsFormVersionId); model.Version = formVersion?.Version?.ToString(); + model.ChefsFormPublished = formVersion?.Published; model.WorksheetLinks = await worksheetLinkAppService.GetListByCorrelationAsync(formVersion?.Id ?? Guid.Empty, CorrelationConsts.FormVersion); model.PublishedWorksheets = [.. (await worksheetListAppService.GetListAsync()) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs index 246c37200..95169c2de 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs @@ -16,6 +16,7 @@ public class CustomFieldsViewModel [JsonRequired] public Guid ChefsFormVersionId { get; set; } + public bool? ChefsFormPublished { get; set; } public string? FormName { get; set; } public string? Version { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml index 253c1c48b..7e80979d4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml @@ -5,7 +5,26 @@ Layout = null; } -

Selected Form Version: @Model.Version

+
+
+ +
+ + @if (Model?.ChefsFormPublished == true) + { + + } +
+
+
+ + + + +
+
@@ -29,15 +48,6 @@
}
- -
-
- - - - -
-
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index 7ef97277a..79fa97983 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -166,12 +166,16 @@ if (tinymce.get("EmailBody")) { tinymce.get("EmailBody").remove(); // remove existing instance } + tinymce.init({ license_key: 'gpl', - selector: `#EmailBody`, + selector: '#EmailBody', plugins: getPlugins(), toolbar: getToolbarOptions(), - statusbar: false, + resize: true, + statusbar: true, + elementpath: false, + branding: false, promotion: false, content_css: false, skin: false, @@ -608,12 +612,16 @@ if (tinymce.get("EmailBody")) { tinymce.get("EmailBody").remove(); // remove existing instance } + tinymce.init({ license_key: 'gpl', - selector: `#EmailBody`, + selector: '#EmailBody', plugins: getPlugins(), toolbar: getToolbarOptions(), - statusbar: false, + resize: true, + statusbar: true, + elementpath: false, + branding: false, promotion: false, content_css: false, skin: false, 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/AddressInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs new file mode 100644 index 000000000..7b38a48e8 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/AddressInfoDataProviderTests.cs @@ -0,0 +1,437 @@ +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Data; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Applicants +{ + public class AddressInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _submissionRepo; + private readonly IRepository _addressRepo; + private readonly IRepository _applicationRepo; + private readonly AddressInfoDataProvider _provider; + + public AddressInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _submissionRepo = Substitute.For>(); + _addressRepo = Substitute.For>(); + _applicationRepo = Substitute.For>(); + + SetupEmptyQueryables(); + + _provider = new AddressInfoDataProvider(_currentTenant, _submissionRepo, _addressRepo, _applicationRepo); + } + + private void SetupEmptyQueryables() + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _addressRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + } + + private void SetupQueryables( + IEnumerable submissions, + IEnumerable addresses, + IEnumerable? applications = null) + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(submissions.AsAsyncQueryable())); + _addressRepo.GetQueryableAsync() + .Returns(Task.FromResult(addresses.AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult((applications ?? []).AsAsyncQueryable())); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.AddressInfo + }; + + private static ApplicationFormSubmission CreateSubmission( + Guid applicationId, string oidcSub, Action? configure = null) + { + var entity = new ApplicationFormSubmission { ApplicationId = applicationId, OidcSub = oidcSub }; + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + configure?.Invoke(entity); + return entity; + } + + private static ApplicantAddress CreateAddress(Action configure) + { + var entity = new ApplicantAddress(); + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + configure(entity); + return entity; + } + + private static Application CreateApplication(Guid id, Action? configure = null) + { + var entity = new Application(); + EntityHelper.TrySetId(entity, () => id); + configure?.Invoke(entity); + return entity; + } + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + result.DataType.ShouldBe("ADDRESSINFO"); + } + + [Fact] + public async Task GetDataAsync_WithNoAddresses_ShouldReturnEmptyList() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldMapAddressFields() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => + { + a.ApplicationId = applicationId; + a.Street = "123 Main St"; + a.Street2 = "Suite 100"; + a.Unit = "4A"; + a.City = "Victoria"; + a.Province = "BC"; + a.Postal = "V8W 1A1"; + a.Country = "Canada"; + a.AddressType = AddressType.PhysicalAddress; + })], + [CreateApplication(applicationId, a => a.ReferenceNo = "REF-001")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + + var address = dto.Addresses[0]; + address.Street.ShouldBe("123 Main St"); + address.Street2.ShouldBe("Suite 100"); + address.Unit.ShouldBe("4A"); + address.City.ShouldBe("Victoria"); + address.Province.ShouldBe("BC"); + address.PostalCode.ShouldBe("V8W 1A1"); + address.Country.ShouldBe("Canada"); + address.AddressType.ShouldBe("Physical"); + address.ReferenceNo.ShouldBe("REF-001"); + address.IsEditable.ShouldBeFalse(); + } + + [Theory] + [InlineData(AddressType.PhysicalAddress, "Physical")] + [InlineData(AddressType.MailingAddress, "Mailing")] + [InlineData(AddressType.BusinessAddress, "Business")] + public async Task GetDataAsync_ShouldMapAddressTypeName(AddressType addressType, string expectedName) + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = addressType; })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses[0].AddressType.ShouldBe(expectedName); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnMultipleAddressesForSameSubmission() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [ + CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = AddressType.PhysicalAddress; a.City = "Victoria"; }), + CreateAddress(a => { a.ApplicationId = applicationId; a.AddressType = AddressType.MailingAddress; a.City = "Vancouver"; }) + ], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnAddressesForOtherSubjects() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "OTHERUSER")], + [CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldHandleNullAddressFields() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateAddress(a => + { + a.ApplicationId = applicationId; + a.Street = null; + a.Street2 = null; + a.Unit = null; + a.City = null; + a.Province = null; + a.Postal = null; + a.Country = null; + })], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + var address = dto.Addresses[0]; + address.Street.ShouldBe(string.Empty); + address.Street2.ShouldBe(string.Empty); + address.Unit.ShouldBe(string.Empty); + address.City.ShouldBe(string.Empty); + address.Province.ShouldBe(string.Empty); + address.PostalCode.ShouldBe(string.Empty); + address.Country.ShouldBe(string.Empty); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnAddressesLinkedByApplicantId() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => s.ApplicantId = applicantId)], + [CreateAddress(a => + { + a.ApplicantId = applicantId; + a.City = "Kelowna"; + a.AddressType = AddressType.MailingAddress; + })]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + dto.Addresses[0].City.ShouldBe("Kelowna"); + dto.Addresses[0].ReferenceNo.ShouldBeNull(); + dto.Addresses[0].IsEditable.ShouldBeTrue(); + } + + [Fact] + public async Task GetDataAsync_ShouldCombineAddressesFromBothLinks() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => s.ApplicantId = applicantId)], + [ + CreateAddress(a => { a.ApplicationId = applicationId; a.City = "Victoria"; }), + CreateAddress(a => { a.ApplicantId = applicantId; a.City = "Kelowna"; }) + ], + [CreateApplication(applicationId, a => a.ReferenceNo = "REF-002")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + } + + [Fact] + public async Task GetDataAsync_ShouldDeduplicateAddressesMatchingBothLinks() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var applicantId = Guid.NewGuid(); + var addressId = Guid.NewGuid(); + + // Same address linked by both ApplicationId and ApplicantId + var sharedAddress = new ApplicantAddress + { + ApplicationId = applicationId, + ApplicantId = applicantId, + City = "Victoria" + }; + EntityHelper.TrySetId(sharedAddress, () => addressId); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => s.ApplicantId = applicantId)], + [sharedAddress], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert — deduplicated to one entry, application-linked (not editable) wins + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(1); + dto.Addresses[0].City.ShouldBe("Victoria"); + dto.Addresses[0].IsEditable.ShouldBeFalse(); + } + + [Fact] + public async Task GetDataAsync_ShouldMarkMostRecentAddressAsPrimaryWhenNoneMarked() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var oldAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Vancouver"; + a.CreationTime = new DateTime(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); + }); + var recentAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Victoria"; + a.CreationTime = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + }); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [oldAddress, recentAddress], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + var primary = dto.Addresses.Single(a => a.IsPrimary); + primary.City.ShouldBe("Victoria"); + } + + [Fact] + public async Task GetDataAsync_ShouldNotOverridePrimaryWhenAlreadySet() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var primaryAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Vancouver"; + a.CreationTime = new DateTime(2023, 1, 1, 10, 0, 0, DateTimeKind.Utc); + a.SetProperty("isPrimary", true); + }); + var recentAddress = CreateAddress(a => + { + a.ApplicationId = applicationId; + a.City = "Victoria"; + a.CreationTime = new DateTime(2023, 6, 15, 14, 30, 0, DateTimeKind.Utc); + }); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [primaryAddress, recentAddress], + [CreateApplication(applicationId)]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Addresses.Count.ShouldBe(2); + var primary = dto.Addresses.Single(a => a.IsPrimary); + primary.City.ShouldBe("Vancouver"); + } + } +} 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..7d7c20fc7 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,16 @@ +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 Unity.GrantManager.Applications; +using Unity.GrantManager.Integrations; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; using Xunit; namespace Unity.GrantManager.Applicants @@ -18,17 +25,60 @@ 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())); + applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync(Arg.Any()) + .Returns(Task.FromResult(new List())); + return new ContactInfoDataProvider(currentTenant, applicantProfileContactService); + } + + private static AddressInfoDataProvider CreateAddressInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var submissionRepo = Substitute.For>(); + submissionRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var addressRepo = Substitute.For>(); + addressRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var applicationRepo = Substitute.For>(); + applicationRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + return new AddressInfoDataProvider(currentTenant, submissionRepo, addressRepo, applicationRepo); + } + + private static SubmissionInfoDataProvider CreateSubmissionInfoDataProvider() + { + var currentTenant = Substitute.For(); + currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + var submissionRepo = Substitute.For>(); + submissionRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var applicationRepo = Substitute.For>(); + applicationRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var statusRepo = Substitute.For>(); + statusRepo.GetQueryableAsync().Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + var endpointManagementAppService = Substitute.For(); + endpointManagementAppService.GetChefsApiBaseUrlAsync().Returns(Task.FromResult(string.Empty)); + var logger = Substitute.For>(); + return new SubmissionInfoDataProvider(currentTenant, submissionRepo, applicationRepo, statusRepo, endpointManagementAppService, logger); + } + [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(); @@ -53,14 +103,14 @@ public async Task OrgInfoDataProvider_GetDataAsync_ShouldReturnOrgInfoDto() [Fact] public void AddressInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new AddressInfoDataProvider(); + var provider = CreateAddressInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.AddressInfo); } [Fact] public async Task AddressInfoDataProvider_GetDataAsync_ShouldReturnAddressInfoDto() { - var provider = new AddressInfoDataProvider(); + var provider = CreateAddressInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.AddressInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -69,14 +119,14 @@ public async Task AddressInfoDataProvider_GetDataAsync_ShouldReturnAddressInfoDt [Fact] public void SubmissionInfoDataProvider_Key_ShouldMatchExpected() { - var provider = new SubmissionInfoDataProvider(); + var provider = CreateSubmissionInfoDataProvider(); provider.Key.ShouldBe(ApplicantProfileKeys.SubmissionInfo); } [Fact] public async Task SubmissionInfoDataProvider_GetDataAsync_ShouldReturnSubmissionInfoDto() { - var provider = new SubmissionInfoDataProvider(); + var provider = CreateSubmissionInfoDataProvider(); var result = await provider.GetDataAsync(CreateRequest(ApplicantProfileKeys.SubmissionInfo)); result.ShouldNotBeNull(); result.ShouldBeOfType(); @@ -103,10 +153,10 @@ public void AllProviders_ShouldHaveUniqueKeys() { IApplicantProfileDataProvider[] providers = [ - new ContactInfoDataProvider(), + CreateContactInfoDataProvider(), new OrgInfoDataProvider(), - new AddressInfoDataProvider(), - new SubmissionInfoDataProvider(), + CreateAddressInfoDataProvider(), + CreateSubmissionInfoDataProvider(), new PaymentInfoDataProvider() ]; diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs new file mode 100644 index 000000000..6e578e896 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs @@ -0,0 +1,319 @@ +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicantProfile.ProfileData; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Integrations; +using Unity.GrantManager.TestHelpers; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Unity.GrantManager.Applicants +{ + public class SubmissionInfoDataProviderTests + { + private readonly ICurrentTenant _currentTenant; + private readonly IRepository _submissionRepo; + private readonly IRepository _applicationRepo; + private readonly IRepository _statusRepo; + private readonly IEndpointManagementAppService _endpointManagementAppService; + private readonly ILogger _logger; + private readonly SubmissionInfoDataProvider _provider; + + public SubmissionInfoDataProviderTests() + { + _currentTenant = Substitute.For(); + _currentTenant.Change(Arg.Any()).Returns(Substitute.For()); + _submissionRepo = Substitute.For>(); + _applicationRepo = Substitute.For>(); + _statusRepo = Substitute.For>(); + _endpointManagementAppService = Substitute.For(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(Task.FromResult(string.Empty)); + _logger = Substitute.For>(); + + SetupEmptyQueryables(); + + _provider = new SubmissionInfoDataProvider( + _currentTenant, _submissionRepo, _applicationRepo, + _statusRepo, _endpointManagementAppService, _logger); + } + + private void SetupEmptyQueryables() + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + _statusRepo.GetQueryableAsync() + .Returns(Task.FromResult(Enumerable.Empty().AsAsyncQueryable())); + } + + private void SetupQueryables( + IEnumerable submissions, + IEnumerable applications, + IEnumerable statuses) + { + _submissionRepo.GetQueryableAsync() + .Returns(Task.FromResult(submissions.AsAsyncQueryable())); + _applicationRepo.GetQueryableAsync() + .Returns(Task.FromResult(applications.AsAsyncQueryable())); + _statusRepo.GetQueryableAsync() + .Returns(Task.FromResult(statuses.AsAsyncQueryable())); + } + + private static ApplicantProfileInfoRequest CreateRequest() => new() + { + ProfileId = Guid.NewGuid(), + Subject = "testuser@idir", + TenantId = Guid.NewGuid(), + Key = ApplicantProfileKeys.SubmissionInfo + }; + + private static ApplicationFormSubmission CreateSubmission( + Guid applicationId, string oidcSub, Action? configure = null) + { + var entity = new ApplicationFormSubmission + { + ApplicationId = applicationId, + OidcSub = oidcSub, + Submission = "{}" + }; + EntityHelper.TrySetId(entity, () => Guid.NewGuid()); + configure?.Invoke(entity); + return entity; + } + + private static Application CreateApplication(Guid id, Guid statusId, Action? configure = null) + { + var entity = new Application { ApplicationStatusId = statusId }; + EntityHelper.TrySetId(entity, () => id); + configure?.Invoke(entity); + return entity; + } + + private static ApplicationStatus CreateStatus(Guid id, string externalStatus) + { + var entity = new ApplicationStatus { ExternalStatus = externalStatus }; + EntityHelper.TrySetId(entity, () => id); + return entity; + } + + [Fact] + public async Task GetDataAsync_ShouldChangeTenant() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + result.DataType.ShouldBe("SUBMISSIONINFO"); + } + + [Fact] + public async Task GetDataAsync_WithNoSubmissions_ShouldReturnEmptyList() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldMapSubmissionFields() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.ChefsSubmissionGuid = "abc-123"; + s.CreationTime = creationTime; + })], + [CreateApplication(applicationId, statusId, a => + { + a.ReferenceNo = "REF-001"; + a.ProjectName = "Test Project"; + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.Count.ShouldBe(1); + + var sub = dto.Submissions[0]; + sub.LinkId.ShouldBe("abc-123"); + sub.ReceivedTime.ShouldBe(creationTime); + sub.ReferenceNo.ShouldBe("REF-001"); + sub.ProjectName.ShouldBe("Test Project"); + sub.Status.ShouldBe("Submitted"); + } + + [Fact] + public async Task GetDataAsync_ShouldResolveSubmissionTimeFromJson() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var chefsCreatedAt = new DateTime(2025, 1, 14, 21, 37, 52, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = """{"createdAt": "2025-01-14T21:37:52.000Z"}"""; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(chefsCreatedAt); + dto.Submissions[0].ReceivedTime.ShouldBe(creationTime); + } + + [Fact] + public async Task GetDataAsync_ShouldFallBackToCreationTimeWhenNoCreatedAt() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = """{"id": "some-id"}"""; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(creationTime); + } + + [Fact] + public async Task GetDataAsync_ShouldFallBackToCreationTimeWhenInvalidJson() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + var creationTime = new DateTime(2025, 1, 15, 10, 30, 0, DateTimeKind.Utc); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER", s => + { + s.CreationTime = creationTime; + s.Submission = "not valid json"; + })], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].SubmissionTime.ShouldBe(creationTime); + } + + [Fact] + public async Task GetDataAsync_ShouldResolveLinkSourceFromIntakeApiBase() + { + // Arrange + var request = CreateRequest(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(Task.FromResult("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1")); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.LinkSource.ShouldBe("https://chefs-dev.apps.silver.devops.gov.bc.ca/app/form/view?s="); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnEmptyLinkSourceWhenSettingFails() + { + // Arrange + var request = CreateRequest(); + _endpointManagementAppService.GetChefsApiBaseUrlAsync() + .Returns(x => throw new Exception("Not configured")); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.LinkSource.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnSubmissionsForOtherSubjects() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "OTHERUSER")], + [CreateApplication(applicationId, statusId)], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions.ShouldBeEmpty(); + } + } +} 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..ba28c5800 --- /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..dde051f27 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoDataProviderTests.cs @@ -0,0 +1,196 @@ +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(); + + _applicantProfileContactService.GetProfileContactsAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + _applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync(Arg.Any()) + .Returns(new List()); + + _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(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + _currentTenant.Received(1).Change(request.TenantId); + } + + [Fact] + public async Task GetDataAsync_ShouldCallGetProfileContactsWithProfileId() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetProfileContactsAsync(request.ProfileId); + } + + [Fact] + public async Task GetDataAsync_ShouldCallGetApplicationContactsWithSubject() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetApplicationContactsBySubjectAsync("TESTUSER"); + } + + [Fact] + public async Task GetDataAsync_ShouldCallGetApplicantAgentContactsWithSubject() + { + // Arrange + var request = CreateRequest(); + + // Act + await _provider.GetDataAsync(request); + + // Assert + await _applicantProfileContactService.Received(1).GetApplicantAgentContactsBySubjectAsync("TESTUSER"); + } + + [Fact] + public async Task GetDataAsync_ShouldCombineAllContactSets() + { + // 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 } + }; + var agentContacts = new List + { + new() { ContactId = Guid.NewGuid(), Name = "Agent Contact 1", IsEditable = false, ContactType = "ApplicantAgent" } + }; + _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId).Returns(profileContacts); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync("TESTUSER").Returns(appContacts); + _applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync("TESTUSER").Returns(agentContacts); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts.Count.ShouldBe(4); + dto.Contacts.Count(c => c.IsEditable).ShouldBe(2); + dto.Contacts.Count(c => !c.IsEditable).ShouldBe(2); + } + + [Fact] + public async Task GetDataAsync_WithNoContacts_ShouldReturnEmptyList() + { + // Arrange + var request = CreateRequest(); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Contacts.ShouldBeEmpty(); + } + + [Fact] + public async Task GetDataAsync_ContactsShouldAppearInExpectedOrder() + { + // 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 + }; + var agentContact = new ContactInfoItemDto + { + ContactId = Guid.NewGuid(), + Name = "Agent Third", + IsEditable = false, + ContactType = "ApplicantAgent" + }; + _applicantProfileContactService.GetProfileContactsAsync(request.ProfileId) + .Returns(new List { profileContact }); + _applicantProfileContactService.GetApplicationContactsBySubjectAsync("TESTUSER") + .Returns(new List { appContact }); + _applicantProfileContactService.GetApplicantAgentContactsBySubjectAsync("TESTUSER") + .Returns(new List { agentContact }); + + // 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"); + dto.Contacts[2].Name.ShouldBe("Agent Third"); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnCorrectDataType() + { + // Arrange + var request = CreateRequest(); + + // 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..874f16fd3 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Contacts/ContactInfoServiceTests.cs @@ -0,0 +1,377 @@ +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 IRepository _applicantAgentRepository; + private readonly IRepository _applicationRepository; + private readonly ApplicantProfileContactService _service; + + public ApplicantProfileContactServiceTests() + { + _contactRepository = Substitute.For(); + _contactLinkRepository = Substitute.For(); + _submissionRepository = Substitute.For>(); + _applicationContactRepository = Substitute.For>(); + _applicantAgentRepository = Substitute.For>(); + _applicationRepository = Substitute.For>(); + + _service = new ApplicantProfileContactService( + _contactRepository, + _contactLinkRepository, + _submissionRepository, + _applicationContactRepository, + _applicantAgentRepository, + _applicationRepository); + } + + 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(); + + var applications = new[] + { + WithId(new Application + { + ReferenceNo = "REF-001" + }, applicationId) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicationContactRepository.GetQueryableAsync().Returns(applicationContacts); + _applicationRepository.GetQueryableAsync().Returns(applications); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("TESTUSER"); + + // 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); + contact.ReferenceNo.ShouldBe("REF-001"); + } + + [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); + _applicationRepository.GetQueryableAsync().Returns( + new[] { WithId(new Application(), applicationId) }.AsAsyncQueryable()); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("DIFFERENTUSER"); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetApplicationContactsBySubjectAsync_WithNoSubmissions_ShouldReturnEmpty() + { + // Arrange + _submissionRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationContactRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("TESTUSER"); + + // 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); + _applicationRepository.GetQueryableAsync().Returns( + new[] + { + WithId(new Application(), appId1), + WithId(new Application(), appId2) + }.AsAsyncQueryable()); + + // Act + var result = await _service.GetApplicationContactsBySubjectAsync("TESTUSER"); + + // Assert + result.Count.ShouldBe(2); + result.ShouldAllBe(c => !c.IsEditable); + result.ShouldAllBe(c => !c.IsPrimary); + } + + [Fact] + public async Task GetApplicantAgentContactsBySubjectAsync_WithMatchingSubmission_ShouldReturnAgentContacts() + { + // Arrange + var applicationId = Guid.NewGuid(); + var agentId = Guid.NewGuid(); + + var submissions = new[] + { + new ApplicationFormSubmission + { + OidcSub = "TESTUSER", + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + ApplicationFormId = Guid.NewGuid() + } + }.AsAsyncQueryable(); + + var agents = new[] + { + WithId(new ApplicantAgent + { + ApplicationId = applicationId, + ApplicantId = Guid.NewGuid(), + Name = "Agent Smith", + Title = "Signing Authority", + Email = "agent@example.com", + Phone = "777-7777", + PhoneExtension = "201", + Phone2 = "888-8888", + RoleForApplicant = "Primary Contact" + }, agentId) + }.AsAsyncQueryable(); + + _submissionRepository.GetQueryableAsync().Returns(submissions); + _applicantAgentRepository.GetQueryableAsync().Returns(agents); + _applicationRepository.GetQueryableAsync().Returns( + new[] { WithId(new Application { ReferenceNo = "REF-AGENT-001" }, applicationId) }.AsAsyncQueryable()); + + // Act + var result = await _service.GetApplicantAgentContactsBySubjectAsync("TESTUSER"); + + // Assert + result.Count.ShouldBe(1); + var contact = result[0]; + contact.ContactId.ShouldBe(agentId); + contact.Name.ShouldBe("Agent Smith"); + contact.Title.ShouldBe("Signing Authority"); + contact.Email.ShouldBe("agent@example.com"); + contact.WorkPhoneNumber.ShouldBe("777-7777"); + contact.WorkPhoneExtension.ShouldBe("201"); + contact.MobilePhoneNumber.ShouldBe("888-8888"); + contact.Role.ShouldBe("Primary Contact"); + contact.ContactType.ShouldBe("ApplicantAgent"); + contact.IsPrimary.ShouldBeFalse(); + contact.IsEditable.ShouldBeFalse(); + contact.ApplicationId.ShouldBe(applicationId); + contact.ReferenceNo.ShouldBe("REF-AGENT-001"); + } + + [Fact] + public async Task GetApplicantAgentContactsBySubjectAsync_WithNoMatchingSubmissions_ShouldReturnEmpty() + { + // Arrange + _submissionRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicantAgentRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + _applicationRepository.GetQueryableAsync() + .Returns(Array.Empty().AsAsyncQueryable()); + + // Act + var result = await _service.GetApplicantAgentContactsBySubjectAsync("TESTUSER"); + + // Assert + result.ShouldBeEmpty(); + } + } +} 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/ApplicantInfoWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicantInfoWidgetTests.cs index 77551b3d5..018a68911 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicantInfoWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicantInfoWidgetTests.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Shouldly; using System; @@ -15,13 +16,14 @@ namespace Unity.GrantManager.Components { - public class ApplicantInfoWidgetTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class ApplicantInfoWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public ApplicantInfoWidgetTests() + public ApplicantInfoWidgetTests(WebTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] 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..a2a78f9f5 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 @@ -12,14 +12,9 @@ namespace Unity.GrantManager.Components { - public class ApplicationContactWidgetTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class ApplicationContactWidgetTests { - public ApplicationContactWidgetTests() - { - // Disable logging to avoid disposed logger errors during tests - Environment.SetEnvironmentVariable("Logging:LogLevel:Default", "None"); - } - [Fact] public async Task ApplicationContactWidgetReturnsStatus() { @@ -64,7 +59,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; diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationStatusWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationStatusWidgetTests.cs index 31fd866bf..387684542 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationStatusWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationStatusWidgetTests.cs @@ -12,6 +12,7 @@ namespace Unity.GrantManager.Components { + [Collection(WebTestCollection.Name)] public class ApplicationStatusWidgetTests { public ApplicationStatusWidgetTests() diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationTagWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationTagWidgetTests.cs index 42d1b1006..c98a74854 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationTagWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ApplicationTagWidgetTests.cs @@ -13,7 +13,8 @@ namespace Unity.GrantManager.Components { - public class ApplicationTagsWidgetTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class ApplicationTagsWidgetTests { [Fact] public async Task ApplicationTagsWidgetReturnsStatus() diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs index b46bac967..332fb0953 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AssessmentScoresWidgetTests.cs @@ -14,7 +14,8 @@ namespace Unity.GrantManager.Components { - public class AssessmentScoresWidgetTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class AssessmentScoresWidgetTests { [Fact] public async Task AssessmentScoresWidgetReturnsStatus() diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs index bf2318ffb..d1f2bcc3f 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/AttachmentControllerTests.cs @@ -13,7 +13,8 @@ namespace Unity.GrantManager.Components { - public class AttachmentControllerTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class AttachmentControllerTests { [Fact] public async Task UploadApplicationAttachments_InvalidInput_ReturnsBadRequest() diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/FundingAgreementInfoWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/FundingAgreementInfoWidgetTests.cs index 3e301ff66..e7b1b902d 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/FundingAgreementInfoWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/FundingAgreementInfoWidgetTests.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Shouldly; using System; @@ -12,13 +13,14 @@ namespace Unity.GrantManager.Components { - public class FundingAgreementInfoWidgetTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class FundingAgreementInfoWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public FundingAgreementInfoWidgetTests() + public FundingAgreementInfoWidgetTests(WebTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ProjectInfoWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ProjectInfoWidgetTests.cs index 1ef14f6c3..44a4ed0ea 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ProjectInfoWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/ProjectInfoWidgetTests.cs @@ -11,31 +11,20 @@ using Volo.Abp.DependencyInjection; using Xunit; using Microsoft.AspNetCore.Authorization; - using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Unity.GrantManager.Components { - public class ProjectInfoWidgetTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class ProjectInfoWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; + private readonly IAuthorizationService authorizationService; - public ProjectInfoWidgetTests() + public ProjectInfoWidgetTests(WebTestFixture fixture) { - // Remove EventLog logger provider to prevent ObjectDisposedException during tests - var loggerFactory = GetRequiredService(); - foreach (var provider in loggerFactory - .GetType() - .GetField("_providers", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.GetValue(loggerFactory) as System.Collections.Generic.List ?? new System.Collections.Generic.List()) - { - if (provider.GetType().Name.Contains("EventLog")) - { - provider.Dispose(); - } - } - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); + authorizationService = fixture.Services.GetRequiredService(); } [Fact] @@ -56,9 +45,6 @@ public async Task ContactInfoReturnsStatus() var electoralDistrictService = Substitute.For(); var regionalDistrictService = Substitute.For(); var communitiesService = Substitute.For(); - var authorizationService = GetRequiredService(); - - var viewContext = new ViewContext { HttpContext = new DefaultHttpContext() @@ -68,7 +54,7 @@ public async Task ContactInfoReturnsStatus() ViewContext = viewContext }; - var viewComponent = new ProjectInfoViewComponent(appService, economicRegionService, electoralDistrictService, regionalDistrictService, communitiesService, authorizationService) + var viewComponent = new ProjectInfoViewComponent(appService, economicRegionService, electoralDistrictService, regionalDistrictService, communitiesService, this.authorizationService) { ViewComponentContext = viewComponentContext, LazyServiceProvider = lazyServiceProvider diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/SummaryWidgetTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/SummaryWidgetTests.cs index 6844c62bc..11d6f2461 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/SummaryWidgetTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Components/SummaryWidgetTests.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewComponents; +using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Shouldly; using System; @@ -14,13 +15,14 @@ namespace Unity.GrantManager.Components { - public class SummaryWidgetTests : GrantManagerWebTestBase + [Collection(WebTestCollection.Name)] + public class SummaryWidgetTests { private readonly IAbpLazyServiceProvider lazyServiceProvider; - public SummaryWidgetTests() + public SummaryWidgetTests(WebTestFixture fixture) { - lazyServiceProvider = GetRequiredService(); + lazyServiceProvider = fixture.Services.GetRequiredService(); } [Fact] diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestBase.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestBase.cs deleted file mode 100644 index 219a06211..000000000 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestBase.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Shouldly; -using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; -using Volo.Abp.AspNetCore.TestBase; - -namespace Unity.GrantManager; - -public abstract class GrantManagerWebTestBase : AbpWebApplicationFactoryIntegratedTest -{ - public static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - protected override void ConfigureServices(IServiceCollection services) - { - // - // 🔹 Remove ALL RabbitMQ hosted services (consumers, registrators, etc.) - // - var hostedServices = services - .Where(d => typeof(Microsoft.Extensions.Hosting.IHostedService) - .IsAssignableFrom(d.ServiceType)) - .ToList(); - - foreach (var descriptor in hostedServices) - { - // Only strip RabbitMQ-related hosted services - if (descriptor.ImplementationType?.Namespace?.Contains("RabbitMQ") == true) - { - services.Remove(descriptor); - } - } - -#if WINDOWS - // 🔹 Remove EventLog logger to avoid ObjectDisposedException in tests - services.RemoveAll(); -#endif - - // - // 🔹 Replace real channel provider with fake - // - services.Replace( - ServiceDescriptor.Singleton() - ); - - base.ConfigureServices(services); - } - - protected virtual async Task GetResponseAsObjectAsync( - string url, - HttpStatusCode expectedStatusCode = HttpStatusCode.OK) - { - var strResponse = await GetResponseAsStringAsync(url, expectedStatusCode); - return JsonSerializer.Deserialize(strResponse, JsonOptions); - } - - protected virtual async Task GetResponseAsStringAsync( - string url, - HttpStatusCode expectedStatusCode = HttpStatusCode.OK) - { - var response = await GetResponseAsync(url, expectedStatusCode); - return await response.Content.ReadAsStringAsync(); - } - - protected virtual async Task GetResponseAsync( - string url, - HttpStatusCode expectedStatusCode = HttpStatusCode.OK) - { - var response = await Client.GetAsync(url); - response.StatusCode.ShouldBe(expectedStatusCode); - return response; - } -} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestModule.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestModule.cs index 7d50f306b..b93bf83e6 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestModule.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestModule.cs @@ -19,7 +19,7 @@ namespace Unity.GrantManager; [DependsOn( typeof(AbpAspNetCoreTestBaseModule), typeof(GrantManagerWebModule), - typeof(GrantManagerApplicationTestModule) + typeof(WebTestInMemoryDbModule) )] public class GrantManagerWebTestModule : AbpModule { diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestStartup.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestStartup.cs deleted file mode 100644 index 913871a3a..000000000 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/GrantManagerWebTestStartup.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Unity.GrantManager; - -public static class GrantManagerWebTestStartup -{ - public static void ConfigureServices(IServiceCollection services) - { - services.AddApplication(); - } - - public static void Configure(IApplicationBuilder app, ILoggerFactory loggerFactory) - { - app.InitializeApplication(); - } -} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Pages/Index_Tests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Pages/Index_Tests.cs index 367e6cde3..9e1c80056 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Pages/Index_Tests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Pages/Index_Tests.cs @@ -1,15 +1,27 @@ -using System.Threading.Tasks; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; using Shouldly; using Xunit; namespace Unity.GrantManager.Pages; -public class Index_Tests : GrantManagerWebTestBase +[Collection(WebTestCollection.Name)] +public class Index_Tests { + private readonly HttpClient _client; + + public Index_Tests(WebTestFixture fixture) + { + _client = fixture.CreateClient(); + } + [Fact] public async Task Welcome_Page() { - var response = await GetResponseAsStringAsync("/"); - response.ShouldNotBeNull(); + var response = await _client.GetAsync("/"); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.ShouldNotBeNullOrEmpty(); } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Unity.GrantManager.Web.Tests.csproj b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Unity.GrantManager.Web.Tests.csproj index e493fd408..608f44b10 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Unity.GrantManager.Web.Tests.csproj +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/Unity.GrantManager.Web.Tests.csproj @@ -21,6 +21,7 @@ + @@ -29,7 +30,7 @@ - + diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/WebTestFixture.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/WebTestFixture.cs new file mode 100644 index 000000000..1c3cc469b --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/WebTestFixture.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; +using Xunit; + +namespace Unity.GrantManager; + +public class WebTestFixture : WebApplicationFactory +{ + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + var hostedServices = services + .Where(d => typeof(IHostedService) + .IsAssignableFrom(d.ServiceType)) + .ToList(); + + foreach (var descriptor in hostedServices) + { + if (descriptor.ImplementationType?.Namespace?.Contains("RabbitMQ") == true) + { + services.Remove(descriptor); + } + } + +if (OperatingSystem.IsWindows()) + { + services.RemoveAll(); + } + + services.Replace( + ServiceDescriptor.Singleton() + ); + }); + } +} + +[CollectionDefinition(WebTestCollection.Name)] +public class WebTestCollection : ICollectionFixture +{ + public const string Name = "WebTests"; +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/WebTestInMemoryDbModule.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/WebTestInMemoryDbModule.cs new file mode 100644 index 000000000..85172f8e1 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Web.Tests/WebTestInMemoryDbModule.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore.GlobalFilters; +using Volo.Abp.FeatureManagement; +using Volo.Abp.Modularity; +using Volo.Abp.PermissionManagement; +using Volo.Abp.Uow; + +namespace Unity.GrantManager; + +[DependsOn( + typeof(GrantManagerEntityFrameworkCoreModule), + typeof(GrantManagerTestBaseModule) +)] +public class WebTestInMemoryDbModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.SaveStaticFeaturesToDatabase = false; + options.IsDynamicFeatureStoreEnabled = false; + }); + + Configure(options => + { + options.SaveStaticPermissionsToDatabase = false; + options.IsDynamicPermissionStoreEnabled = false; + }); + + Configure(options => + { + options.UseDbFunction = false; + }); + + context.Services.AddAlwaysDisableUnitOfWorkTransaction(); + + var inMemoryDatabaseName = $"WebTests_{System.Guid.NewGuid():N}"; + + Configure(options => + { + options.Configure(abpDbContextConfigurationContext => + { + abpDbContextConfigurationContext.DbContextOptions + .UseInMemoryDatabase(inMemoryDatabaseName); + }); + }); + } +} diff --git a/applications/Unity.Tools/README.md b/applications/Unity.Tools/README.md index 7adc5859f..856a9d6c7 100644 --- a/applications/Unity.Tools/README.md +++ b/applications/Unity.Tools/README.md @@ -4,5 +4,5 @@ This directory contains supporting tools and services for the Unity platform: - [Unity.Metabase](Unity.Metabase/README.md): Reserved for Metabase integration or related resources. - [Unity.NginxData](Unity.NginxData/README.md): Nginx HTTP server and reverse proxy S2I application, with reference files for forms and reporting. -- [Unity.RabbitMQ](Unity.RabbitMQ/README.md): RabbitMQ message broker setup for OpenShift, including user and vhost configuration. +- [Unity.RabbitMQ](Unity.RabbitMQ/README.md): RabbitMQ message broker user and vhost configuration. - [Unity.RedisSentinel](Unity.RedisSentinel/README.md): Docker Compose configuration for Redis with Sentinel for high availability. diff --git a/applications/Unity.Tools/Unity.Metabase/README.md b/applications/Unity.Tools/Unity.Metabase/README.md index b0359d7ce..9dddbdd79 100644 --- a/applications/Unity.Tools/Unity.Metabase/README.md +++ b/applications/Unity.Tools/Unity.Metabase/README.md @@ -1,3 +1,210 @@ -# Unity Metabase +# Metabase Configuration -This directory is reserved for the Unity Metabase integration resources. +This directory contains a Docker Compose configuration for setting up Metabase for local development and analytics. + +## Overview + +The setup provides: + +- **Metabase Analytics Platform**: Business intelligence and data visualization +- **PostgreSQL Database**: Metabase application database for storing dashboards, users, etc. +- **Persistent Storage**: Data persistence across container restarts + +## Getting Started + +### Basic Usage + +Start Metabase: + +```bash +docker-compose up +``` + +To run in detached mode: + +```bash +docker-compose up -d +``` + +### Configuration Options + +This setup supports environment variables for customization: + +| Variable | Default | Description | +|----------|---------|-------------| +| `MB_DB_DBNAME` | `metabase` | Metabase application database name | +| `MB_DB_USER` | `metabase` | Metabase database username | +| `MB_DB_PASS` | `metabase123` | Metabase database password | +| `POSTGRES_USER` | `metabase` | PostgreSQL superuser username | +| `POSTGRES_PASSWORD` | `metabase123` | PostgreSQL superuser password | + +#### Custom Database Configuration + +You can set custom database credentials: + +```bash +# PowerShell +$env:MB_DB_PASS="mysecurepassword"; $env:POSTGRES_PASSWORD="mysecurepassword"; docker-compose up + +# Bash/CMD +MB_DB_PASS=mysecurepassword POSTGRES_PASSWORD=mysecurepassword docker-compose up +``` + +Alternatively, create a `.env` file in the same directory: + +```config +MB_DB_DBNAME=metabase +MB_DB_USER=metabase +MB_DB_PASS=mysecurepassword +POSTGRES_USER=metabase +POSTGRES_PASSWORD=mysecurepassword +``` + +### Accessing Metabase + +#### Web Interface + +- **URL**: http://localhost:3000 +- **First-time setup**: You'll be prompted to create an admin account +- **Default admin**: Create during initial setup + +#### Database Connection for Unity Data + +When setting up data sources in Metabase to connect to your Unity databases: + +**For Unity PostgreSQL (from Unity.GrantManager docker-compose):** +- **Host**: `host.docker.internal` (Windows/Mac) or your machine's IP +- **Port**: `5432` (or your Unity DB port) +- **Database**: Your Unity database name +- **Username/Password**: Your Unity database credentials + +## Initial Setup + +### First-Time Configuration + +1. Start Metabase: `docker-compose up` +2. Wait for services to fully start (check logs) +3. Navigate to http://localhost:3000 +4. Complete the initial setup wizard: + - Create admin account + - Skip adding data source (or add Unity database) + - Finish setup + +### Connecting to Unity Data + +To analyze Unity application data: + +1. **Add Database** in Metabase +2. **Select PostgreSQL** +3. **Connection details**: + ``` + Host: host.docker.internal + Port: 5432 (or your Unity DB port) + Database name: [Your Unity DB name] + Username: [Your Unity DB user] + Password: [Your Unity DB password] + ``` + +### Example Unity Database Connection + +If using the Unity.GrantManager docker-compose setup: + +```json +{ + "host": "host.docker.internal", + "port": 5432, + "database": "postgres", + "username": "postgres", + "password": "admin" +} +``` + +## Verifying the Setup + +Check Metabase status: + +```bash +# Check if services are running +docker ps | grep metabase + +# Check Metabase logs +docker-compose logs metabase + +# Check PostgreSQL logs +docker-compose logs metabase-db +``` + +Test web interface: + +```bash +curl http://localhost:3000/api/health +``` + +## Stopping and Cleanup + +Stop Metabase: + +```bash +docker-compose down +``` + +Remove volumes (this will delete all dashboards and configuration): + +```bash +docker-compose down -v +``` + +## Notes & Limitations + +- This setup is designed for local development and testing +- For production deployments, use proper secrets management +- The first startup takes longer as Metabase initializes its database +- Dashboards and questions are stored in the PostgreSQL database + +## Troubleshooting + +### Common Issues + +1. **Slow startup**: Metabase can take 2-3 minutes to fully initialize on first run +2. **Port conflicts**: If port 3000 is in use, modify the port mapping in `docker-compose.yml` +3. **Database connection issues**: Ensure your Unity database is accessible from Docker + +### Useful Commands + +```bash +# Check Metabase initialization status +docker-compose logs -f metabase | grep -i "metabase initialization" + +# Reset Metabase (removes all dashboards/config) +docker-compose down -v && docker-compose up + +# Access PostgreSQL directly +docker exec -it metabase-db psql -U metabase -d metabase +``` + +## Integration with Unity Applications + +This Metabase setup is designed to work with Unity applications for: + +- **Analytics Dashboards**: Visualize Unity application data +- **Business Intelligence**: Generate reports from Unity databases +- **Data Monitoring**: Track application metrics and KPIs +- **User Insights**: Analyze user behavior and application usage + +### Common Unity Analytics Use Cases + +- Grant application metrics and trends +- User engagement and portal usage +- Application performance monitoring +- Business process analytics +- Compliance and audit reporting + +## Production Considerations + +When deploying to production environments: + +- Use external PostgreSQL database instead of containerized one +- Implement proper backup strategies for dashboards and configuration +- Set up proper authentication integration (LDAP, SAML, etc.) +- Configure SSL/TLS for secure connections +- Use environment-specific connection strings diff --git a/applications/Unity.Tools/Unity.Metabase/docker-compose.yml b/applications/Unity.Tools/Unity.Metabase/docker-compose.yml new file mode 100644 index 000000000..c758c444a --- /dev/null +++ b/applications/Unity.Tools/Unity.Metabase/docker-compose.yml @@ -0,0 +1,62 @@ +services: + metabase: + image: metabase/metabase:v0.51.4 + container_name: unity-metabase + hostname: unity-metabase + environment: + - MB_DB_TYPE=postgres + - MB_DB_DBNAME=${MB_DB_DBNAME:-metabase} + - MB_DB_PORT=5432 + - MB_DB_USER=${MB_DB_USER:-metabase} + - MB_DB_PASS=${MB_DB_PASS:-metabase123} + - MB_DB_HOST=metabase-db + - JAVA_OPTS=-Xmx1024m -Xss1m -Dfile.encoding=UTF-8 -Dlogfile.path=target/log -server + ports: + - "3000:3000" + - "8443:8443" + depends_on: + metabase-db: + condition: service_healthy + networks: + - metabase-network + volumes: + - metabase-data:/metabase-data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + restart: unless-stopped + + metabase-db: + image: postgres:15 + container_name: metabase-db + hostname: metabase-db + environment: + - POSTGRES_DB=${MB_DB_DBNAME:-metabase} + - POSTGRES_USER=${POSTGRES_USER:-metabase} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-metabase123} + ports: + - "5433:5432" # Different port to avoid conflicts with Unity DB + networks: + - metabase-network + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-metabase} -d ${MB_DB_DBNAME:-metabase}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + +networks: + metabase-network: + driver: bridge + +volumes: + metabase-data: + driver: local + postgres-data: + driver: local \ No newline at end of file diff --git a/applications/Unity.Tools/Unity.RabbitMQ/README.md b/applications/Unity.Tools/Unity.RabbitMQ/README.md index ec86d535d..d5f3f6708 100644 --- a/applications/Unity.Tools/Unity.RabbitMQ/README.md +++ b/applications/Unity.Tools/Unity.RabbitMQ/README.md @@ -1,61 +1,215 @@ -# Unity RabbitMQ +# RabbitMQ Configuration -This directory contains the setup for RabbitMQ message broker in an OpenShift container. It includes configuration for administrator and client users, as well as virtual hosts for development environments. +This directory contains a Docker Compose configuration for setting up RabbitMQ for local development. -## Contents -- RabbitMQ configuration files -- User and vhost setup instructions +## Overview -See the README for setup and usage instructions. +The setup provides: -Setup of RabbitMQ message broker in an OpenShift container requires an administrator user (`unity-admin`) and two client users each associated with their own virtual hosts (`/dev` and `/dev2`). +- **RabbitMQ Server**: Message broker for Unity applications +- **Management Interface**: Web-based management and monitoring +- **Persistent Storage**: Data persistence across container restarts -## Prerequisites +## Getting Started -- OpenShift cluster access -- RabbitMQ installed on your OpenShift cluster -- RabbitMQ CLI tools (`rabbitmqctl`) +### Basic Usage -## Setup +Start RabbitMQ: -### Creating Virtual Hosts +```bash +docker-compose up +``` + +To run in detached mode: + +```bash +docker-compose up -d +``` + +### Configuration Options + +This setup supports environment variables for customization: + +| Variable | Default | Description | +|----------|---------|-------------| +| `RABBITMQ_DEFAULT_USER` | `admin` | Default RabbitMQ username | +| `RABBITMQ_DEFAULT_PASS` | `admin` | Default RabbitMQ password | + +#### Custom Credentials + +You can set custom RabbitMQ credentials: + +```bash +# PowerShell +$env:RABBITMQ_DEFAULT_USER="myuser"; $env:RABBITMQ_DEFAULT_PASS="mypassword"; docker-compose up + +# Bash/CMD +RABBITMQ_DEFAULT_USER=myuser RABBITMQ_DEFAULT_PASS=mypassword docker-compose up +``` + +Alternatively, create a `.env` file in the same directory: + +```config +RABBITMQ_DEFAULT_USER=myuser +RABBITMQ_DEFAULT_PASS=mypassword +``` + +### Accessing RabbitMQ + +#### Management Interface + +- **URL**: http://localhost:15672 +- **Username**: `admin` (or your custom user) +- **Password**: `admin` (or your custom password) + +#### AMQP Connection + +- **Host**: localhost +- **Port**: 5672 +- **Username**: `admin` (or your custom user) +- **Password**: `admin` (or your custom password) + +### Client Application Configuration + +For Unity applications, configure your `appsettings.json` as follows: + +```json +{ + "RabbitMQ": { + "Host": "localhost", + "Port": 5672, + "Username": "admin", + "Password": "admin", + "VirtualHost": "/", + "ExchangeName": "unity.exchange", + "QueueName": "unity.queue" + } +} +``` + +#### Configuration Examples -To create the virtual hosts `/dev` and `/dev2`, use the following commands: +For local development: -```sh -rabbitmqctl add_vhost /dev -rabbitmqctl add_vhost /dev2 +```json +{ + "RabbitMQ": { + "Host": "localhost", + "Port": 5672, + "Username": "admin", + "Password": "admin" + } +} ``` -### Adding Users and Setting Permissions +For Docker network communication: + +```json +{ + "RabbitMQ": { + "Host": "rabbitmq", + "Port": 5672, + "Username": "admin", + "Password": "admin" + } +} +``` + +For Kubernetes deployment: + +```json +{ + "RabbitMQ": { + "Host": "unity-rabbitmq.namespace.svc.cluster.local", + "Port": 5672, + "Username": "admin", + "Password": "your-secure-password" + } +} +``` + +## Verifying the Setup -Create the administrator user `unity-admin`: +Check RabbitMQ status: -```sh -rabbitmqctl add_user unity-admin 'your_admin_password' -rabbitmqctl set_permissions -p / unity-admin ".*" ".*" ".*" -rabbitmqctl set_user_tags unity-admin administrator +```bash +# Check if RabbitMQ is running +docker ps | grep rabbitmq + +# Check logs +docker-compose logs rabbitmq ``` -Create the client user `unity-rabbitmq-user-dev` for the `/dev` vhost: +Test connection using management API: -```sh -rabbitmqctl add_user unity-rabbitmq-user-dev 'your_dev_password' -rabbitmqctl set_permissions -p /dev unity-rabbitmq-user-dev ".*" ".*" ".*" +```bash +curl -u admin:admin http://localhost:15672/api/overview ``` -Create the client user `unity-rabbitmq-user-dev2` for the `/dev2` vhost: +## Stopping and Cleanup + +Stop RabbitMQ: -```sh -rabbitmqctl add_user unity-rabbitmq-user-dev2 'your_dev2_password' -rabbitmqctl set_permissions -p /dev2 unity-rabbitmq-user-dev2 ".*" ".*" ".*" +```bash +docker-compose down ``` -## Volume Mounts +Remove volumes (this will delete all data): + +```bash +docker-compose down -v +``` + +## Notes & Limitations + +- This setup is designed for local development and testing +- For production deployments, use proper secrets management +- The management interface is exposed on all interfaces (0.0.0.0) +- Data persists in Docker volumes between restarts + +## Troubleshooting + +### Common Issues + +1. **Port Already in Use**: If ports 5672 or 15672 are already in use, modify the port mappings in `docker-compose.yml` -To persist RabbitMQ data a container volume mount is required with backup to offsite S3 storage. +2. **Permission Issues**: Ensure Docker has proper permissions to create volumes -```yaml -volumeMounts: - - mountPath: /var/lib/rabbitmq +3. **Connection Refused**: Check that RabbitMQ has fully started by monitoring the logs: + ```bash + docker-compose logs -f rabbitmq + ``` + +### RabbitMQ Management Commands + +Useful management commands via the web interface or CLI: + +```bash +# List queues +docker exec rabbitmq rabbitmqctl list_queues + +# List exchanges +docker exec rabbitmq rabbitmqctl list_exchanges + +# List users +docker exec rabbitmq rabbitmqctl list_users + +# Add user +docker exec rabbitmq rabbitmqctl add_user newuser newpassword + +# Set permissions +docker exec rabbitmq rabbitmqctl set_permissions -p / newuser ".*" ".*" ".*" ``` + +## Integration with Unity Applications + +This RabbitMQ setup is designed to work seamlessly with Unity applications that require message queuing capabilities. The configuration matches the OpenShift deployment specifications for consistency across development and production environments. + +### Message Patterns + +Common RabbitMQ patterns used in Unity applications: + +- **Work Queues**: Distributing tasks among workers +- **Publish/Subscribe**: Broadcasting messages to multiple consumers +- **Routing**: Selective message routing based on criteria +- **Topics**: Complex routing patterns with wildcards \ No newline at end of file diff --git a/applications/Unity.Tools/Unity.RabbitMQ/docker-compose.yml b/applications/Unity.Tools/Unity.RabbitMQ/docker-compose.yml new file mode 100644 index 000000000..fe0f05e3e --- /dev/null +++ b/applications/Unity.Tools/Unity.RabbitMQ/docker-compose.yml @@ -0,0 +1,33 @@ +services: + rabbitmq: + image: rabbitmq:4.2-management + container_name: unity-rabbitmq + hostname: unity-rabbitmq + environment: + - RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER:-admin} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS:-admin} + - RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS=-rabbit log_levels [{connection,error},{default,info}] + ports: + - "5672:5672" # AMQP port + - "15672:15672" # Management interface port + - "15692:15692" # Prometheus metrics port (optional) + - "25672:25672" # Inter-node and CLI tool communication port + volumes: + - rabbitmq-data:/var/lib/rabbitmq + networks: + - rabbitmq-network + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s + restart: unless-stopped + +networks: + rabbitmq-network: + driver: bridge + +volumes: + rabbitmq-data: + driver: local \ No newline at end of file diff --git a/applications/Unity.Tools/Unity.RabbitMQ/rabbit@unity-rabbitmq-1-feature_flags b/applications/Unity.Tools/Unity.RabbitMQ/rabbit@unity-rabbitmq-1-feature_flags deleted file mode 100644 index 073f94dde..000000000 --- a/applications/Unity.Tools/Unity.RabbitMQ/rabbit@unity-rabbitmq-1-feature_flags +++ /dev/null @@ -1,23 +0,0 @@ -[classic_mirrored_queue_version, - classic_queue_type_delivery_support, - detailed_queues_endpoint, - direct_exchange_routing_v2, - drop_unroutable_metric, - empty_basic_get_metric, - feature_flags_v2, - implicit_default_bindings, - listener_records_in_ets, - maintenance_mode_status, - message_containers, - message_containers_deaths_v2, - quorum_queue, - quorum_queue_non_voters, - restart_streams, - stream_filtering, - stream_queue, - stream_sac_coordinator_unblock_group, - stream_single_active_consumer, - stream_update_config_command, - tracking_records_in_ets, - user_limits, - virtual_host_metadata]. \ No newline at end of file diff --git a/database/.gitkeep b/database/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/database/crunchy-postgres/.helmignore b/database/crunchy-postgres/.helmignore deleted file mode 100644 index 0e8a0eb36..000000000 --- a/database/crunchy-postgres/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/database/crunchy-postgres/Chart.yaml b/database/crunchy-postgres/Chart.yaml deleted file mode 100644 index ec6ceaee2..000000000 --- a/database/crunchy-postgres/Chart.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: v2 -name: crunchy-postgres -description: High Availability CrunchyDB Operator Chart - -icon: https://www.postgresql.org/media/img/about/press/elephant.png - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.4 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. - -# Crunchy Postgres Operator version -appVersion: "5.0.4" diff --git a/database/crunchy-postgres/README.md b/database/crunchy-postgres/README.md deleted file mode 100644 index 3302e44b0..000000000 --- a/database/crunchy-postgres/README.md +++ /dev/null @@ -1,202 +0,0 @@ -# Crunchy Postgres chart - -A chart to provision a [Crunchy Postgres](https://www.crunchydata.com/) cluster. - -## Configuration -Apply base configuration from values.yaml and make the necessary overrides in custom-values-example.yaml -```Bash -helm upgrade --install new-hippo-ha . -f values.yaml -f custom-values-example.yaml -``` -### Crunchy Options - -| Parameter | Description | Default | -| ------------------ | ---------------------- | ------------------ | -| `fullnameOverride` | Override release name | `crunchy-postgres` | -| `crunchyImage` | Crunchy Postgres image | | -| `postgresVersion` | Postgres version | `15` | - ---- - -### Instances - -| Parameter | Description | Default | -| ------------------------------------------- | ------------------------------ | ------------------------ | -| `instances.name` | Instance name | `ha` (high availability) | -| `instances.replicas` | Number of replicas | `2` | -| `instances.dataVolumeClaimSpec.storage` | Amount of storage for each PVC | `256Mi` | -| `instances.requests.cpu` | CPU requests | `1m` | -| `instances.requests.memory` | Memory requests | `256Mi` | -| `instances.limits.cpu` | CPU limits | `100m` | -| `instances.limits.memory` | Memory limits | `512Mi` | -| `instances.replicaCertCopy.requests.cpu` | replicaCertCopy CPU requests | `1m` | -| `instances.replicaCertCopy.requests.memory` | replicaCertCopyMemory requests | `32Mi` | -| `instances.replicaCertCopy.limits.cpu` | replicaCertCopyCPU limits | `50m` | -| `instances.replicaCertCopy.limits.memory` | replicaCertCopy Memory limits | `64Mi` | - ---- - -### pgBackRest - Reliable PostgreSQL Backup & Restore - -[pgBackRest site](https://pgbackrest.org/) -[Crunchy pgBackRest docs](https://access.crunchydata.com/documentation/pgbackrest/latest/) - -| Parameter | Description | Default | -| ---------------------------------------------------- | ------------------------------------------------------------- | ---------------------- | -| `pgBackRest.image` | Crunchy pgBackRest | | -| `pgBackRest.retention` | Number of backups/days to keep depending on retentionFullType | `2` | -| `pgBackRest.retentionFullType` | Either 'count' or 'time' | `count` | -| `pgBackRest.repos.schedules.full` | Full backup schedule | `0 8 * * *` | -| `pgBackRest.repos.schedules.incremental` | Incremental backup schedule | `0 0,4,12,16,20 * * *` | -| `pgBackRest.repos.schedules.volume.addessModes` | Access modes | `ReadWriteOnce` | -| `pgBackRest.repos.schedules.volume.storage` | PVC size | `128Mi` | -| `pgBackRest.repos.schedules.volume.storageClassName` | Storage class name modes | `netapp-file-backup` | -| `pgBackRest.repoHost.requests.cpu` | CPU requests | `1m` | -| `pgBackRest.repoHost.requests.memory` | Memory requests | `64Mi` | -| `pgBackRest.repoHost.limits.cpu` | CPU limits | `50m` | -| `pgBackRest.repoHost.limits.memory` | Memory limits | `128Mi` | -| `pgBackRest.sidecars.requests.cpu` | sidecars CPU requests | `1m` | -| `pgBackRest.sidecars.requests.memory` | sidecars Memory requests | `64Mi` | -| `pgBackRest.sidecars.limits.cpu` | sidecars CPU limits | `50m` | -| `pgBackRest.sidecars.limits.memory` | sidecars Memory limits | `128Mi` | -| `pgBackRest.s3.enabled` | Enables the s3 repo backups | `false` | -| `pgBackRest.s3.createS3Secret` | Creates the s3 secret based on key and keySecret | `true` | -| `pgBackRest.s3.s3Secret` | The secret name to be created or read from | `s3-pgbackrest` | -| `pgBackRest.s3.s3Path` | The path inside the bucket where the backups will be saved to, set it to `/` to use the root of the bucket. | `/dbbackup` | -| `pgBackRest.s3.s3UriStyle` | Style of URL to use for S3 communication. [More Info](https://pgbackrest.org/configuration.html#section-repository/option-repo-s3-uri-style) | `path` | -| `pgBackRest.s3.bucket` | The bucket to use for backups | `bucketName` | -| `pgBackRest.s3.endpoint` | The endpoint to use, for example s3.ca-central-1.amazonaws.com | `endpointName` | -| `pgBackRest.s3.region` | The region to use, not necessary if your S3 system does not specify one | `ca-central-1` | -| `pgBackRest.s3.key` | The key to use to access the bucket. MUST BE KEPT SECRET | `s3KeyValue` | -| `pgBackRest.s3.keySecret` | The key secret for the key set above. MUST BE KEPT SECRET | `s3SecretValue` | ---- - -### Patroni - -[Patroni docs](https://patroni.readthedocs.io/en/latest/) -[Crunchy Patroni docs](https://access.crunchydata.com/documentation/patroni/latest/) - -| Parameter | Description | Default | -| ------------------------------------------- | ------------------------------------------------------------------- | --------------------------------- | -| `patroni.postgresql.pg_hba` | pg_hba permissions | `"host all all 0.0.0.0/0 md5"` | -| `crunchyImage` | Crunchy Postgres image | `...crunchy-postgres:ubi8-14.7-0` | -| `patroni.parameters.shared_buffers` | The number of shared memory buffers used by the server | `16MB` | -| `patroni.parameters.wal_buffers` | The number of disk-page buffers in shared memory for WAL | `64KB` | -| `patroni.parameters.min_wal_size` | The minimum size to shrink the WAL to | `32MB` | -| `patroni.parameters.max_wal_size` | Sets the WAL size that triggers a checkpoint | `64MB` | -| `patroni.parameters.max_slot_wal_keep_size` | Sets the maximum WAL size that can be reserved by replication slots | `128MB` | - ---- - -### pgBouncer - -A lightweight connection pooler for PostgreSQL - -[pgBouncer site](https://www.pgbouncer.org/) -[Crunchy Postgres pgBouncer docs](https://access.crunchydata.com/documentation/pgbouncer/latest/) - -| Parameter | Description | Default | -| --------------------------------- | ----------------------- | ------- | -| `proxy.pgBouncer.image` | Crunchy pgBouncer image | | -| `proxy.pgBouncer.replicas` | Number of replicas | `2` | -| `proxy.pgBouncer.requests.cpu` | CPU requests | `1m` | -| `proxy.pgBouncer.requests.memory` | Memory requests | `64Mi` | -| `proxy.pgBouncer.limits.cpu` | CPU limits | `50m` | -| `proxy.pgBouncer.limits.memory` | Memory limits | `128Mi` | - ---- - -## PG Monitor - -[Crunchy Postgres PG Monitor docs](https://access.crunchydata.com/documentation/pgmonitor/latest/) - -| Parameter | Description | Default | -| ------------------------------------ | ---------------------------------------------- | ------- | -| `pgmonitor.enabled` | Enable PG Monitor (currently only PG exporter) | `false` | -| `pgmonitor.exporter.requests.cpu` | PG Monitor CPU requests | `1m` | -| `pgmonitor.exporter.requests.memory` | PG Monitor Memory requests | `64Mi` | -| `pgmonitor.exporter.limits.cpu` | PG Monitor CPU limits | `50m` | -| `pgmonitor.exporter.limits.memory` | PG Monitor Memory limits | `128Mi` | - -#### Postgres Exporter - -A [Prometheus](https://prometheus.io/) exporter for PostgreSQL - -[Postgres Exporter](https://github.com/prometheus-community/postgres_exporter) - -| Parameter | Description | Default | -| ------------------------------------ | ------------------------- | ------- | -| `pgmonitor.exporter.image` | Crunchy PG Exporter image | | -| `pgmonitor.exporter.requests.cpu` | CPU requests | `1m` | -| `pgmonitor.exporter.requests.memory` | Memory requests | `64Mi` | -| `pgmonitor.exporter.limits.cpu` | CPU limits | `50m` | -| `pgmonitor.exporterr.limits.memory` | Memory limits | `128Mi` | - ---- - -## Data Restore CronJob - -This feature allows you to set up a daily CronJob that restores data from a source S3 repository (e.g., from another database instance) into the current PostgreSQL cluster. This is useful for change data capture scenarios where you need to regularly sync data from a source database. The configuration reuses the same structure as `dataSource` and `pgBackRest.s3` for consistency. - -### Configuration - -| Parameter | Description | Default | -| ---------------------------------------------- | ----------------------------------------------------- | ---------------------- | -| `dataRestore.enabled` | Enable the data restore CronJob | `false` | -| `dataRestore.schedule` | Cron schedule for the restore job | `"0 2 * * *"` | -| `dataRestore.image` | pgBackRest image to use for restore | `crunchy-pgbackrest` | -| `dataRestore.secretName` | K8s secret containing S3 credentials (reuse existing)| `s3-pgbackrest` | -| `dataRestore.repo.name` | Repository name (repo1, repo2, etc.) | `repo2` | -| `dataRestore.repo.path` | S3 path prefix | `/habackup` | -| `dataRestore.repo.s3.bucket` | Source S3 bucket name | `bucketName` | -| `dataRestore.repo.s3.endpoint` | S3 endpoint URL | Object store endpoint | -| `dataRestore.repo.s3.region` | S3 region | `not-used` | -| `dataRestore.repo.s3.uriStyle` | S3 URI style (path or host) | `path` | -| `dataRestore.stanza` | pgBackRest stanza name | `db` | -| `dataRestore.target.clusterName` | Target cluster name (defaults to current cluster) | `""` | -| `dataRestore.target.database` | Target database name | `postgres` | -| `dataRestore.resources.requests.cpu` | CPU requests for restore job | `100m` | -| `dataRestore.resources.requests.memory` | Memory requests for restore job | `256Mi` | -| `dataRestore.resources.limits.cpu` | CPU limits for restore job | `500m` | -| `dataRestore.resources.limits.memory` | Memory limits for restore job | `512Mi` | -| `dataRestore.successfulJobsHistoryLimit` | Number of successful jobs to keep in history | `3` | -| `dataRestore.failedJobsHistoryLimit` | Number of failed jobs to keep in history | `1` | -| `dataRestore.restartPolicy` | Pod restart policy for failed jobs | `OnFailure` | -| `dataRestore.additionalArgs` | Additional pgbackrest arguments | `[]` | - -### Usage Example - -The configuration reuses existing S3 secrets and follows the same patterns as `dataSource`: - -```yaml -dataRestore: - enabled: true - schedule: "0 2 * * *" # Daily at 2 AM - # Reuse existing S3 secret from dataSource or pgBackRest.s3 - secretName: "dev-s3-pgbackrest" - repo: - name: repo2 - path: "/habackup-source-database" - s3: - bucket: "source-database-backups" - endpoint: "https://sector.objectstore.gov.bc.ca" - region: "not-used" - uriStyle: "path" - stanza: db - target: - database: "myapp" - additionalArgs: - - "--log-level-console=debug" - - "--process-max=2" -``` - -### Important Notes - -- The restore uses `--delta` mode, which only restores changed files for efficiency -- Reuses existing S3 secrets from `dataSource` or `pgBackRest.s3` configuration -- The job runs with the specified S3 repository as the source -- Ensure the source S3 repository contains valid pgBackRest backups -- The target cluster must be accessible and have proper credentials -- Monitor CronJob logs for restore status and any errors -- Configuration follows the same patterns as `dataSource` for consistency - ---- diff --git a/database/crunchy-postgres/custom-values-example.yaml b/database/crunchy-postgres/custom-values-example.yaml deleted file mode 100644 index f8646e241..000000000 --- a/database/crunchy-postgres/custom-values-example.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# Apply base configuration from values.yaml and make the necessary overrides in custom-values-example.yaml -# helm upgrade --install new-hippo-ha . -f values.yaml -f custom-values-example.yaml - -fullnameOverride: new-crunchy-postgres - -labels: - app.kubernetes.io/part-of: new-crunchydb-postgres - -dataSource: - enabled: false - # should have the same name and contain the same keys as the pgbackrest secret - secretName: new-s3-pgbackrest - repo: - path: "/habackup-new" - bucket: "sector-project-new" - endpoint: "https://sector.objectstore.gov.bc.ca" - -pgBackRest: - repos: - schedules: - full: 10 10 * * * - incremental: 10 3,15,19,23 * * * - s3: - enabled: false - createS3Secret: false - # the s3 secret name - s3Secret: new-s3-pgbackrest - # the path start with /, it will be created under bucket if it doesn't exist - s3Path: "/habackup-new" - # bucket specifies the S3 bucket to use, - bucket: "sector-project-new" - # endpoint specifies the S3 endpoint to use. - endpoint: "https://sector.objectstore.gov.bc.ca" - # key is the S3 key. This is stored in a Secret. - # Please DO NOT push this value to GitHub - key: "s3keyValue" - # keySecret is the S3 key secret. This is stored in a Secret. - # Please DO NOT push this value to GitHub - keySecret: "s3SecretValue" - # set the default schedule to avoid conflicts - fullSchedule: 30 11 * * * - incrementalSchedule: 30 3,15,19,23 * * * - -# Data restore cronjob configuration example -# Uncomment and configure to enable daily restore from source database -# Reuses the same structure as dataSource for consistency -# dataRestore: -# enabled: true -# schedule: "0 2 * * *" # Daily at 2 AM -# image: "artifacts.developer.gov.bc.ca/bcgov-docker-local/crunchy-pgbackrest:ubi8-2.47-1" -# secretName: "new-s3-pgbackrest" -# repo: -# name: repo2 -# path: "/habackup-source" -# bucket: "source-database-backups" -# endpoint: "https://sector.objectstore.gov.bc.ca" -# region: "not-used" -# uriStyle: "path" -# stanza: db -# target: -# clusterName: "" -# database: "myapp" -# resources: -# requests: -# cpu: 200m -# memory: 512Mi -# limits: -# cpu: 1000m -# memory: 1Gi -# additionalArgs: -# - "--log-level-console=debug" -# - "--process-max=2" \ No newline at end of file diff --git a/database/crunchy-postgres/templates/PostgresCluster.yaml b/database/crunchy-postgres/templates/PostgresCluster.yaml deleted file mode 100644 index cb6d0f61b..000000000 --- a/database/crunchy-postgres/templates/PostgresCluster.yaml +++ /dev/null @@ -1,254 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: {{ template "crunchy-postgres.fullname" . }} - labels: - helm.sh/chart: {{ include "crunchy-postgres.chart" . }} - app.kubernetes.io/name: {{ include "crunchy-postgres.name" . }} - app.kubernetes.io/instance: {{ include "crunchy-postgres.fullname" . }} - {{- if .Chart.AppVersion }} - app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} - {{- end }} - app.kubernetes.io/managed-by: {{ .Release.Service }} - {{- range $key, $value := .Values.labels }} - {{ $key }}: {{ $value | quote }} - {{- end }} - app.kubernetes.io/component: "database" - {{- if .Values.annotations }} - annotations: - {{- range $key, $value := .Values.annotations }} - {{ $key }}: {{ $value | quote }} - {{- end }} - {{- end }} -spec: - openshift: {{ .Values.openshift | default false }} - {{- if .Values.shutdown }} - shutdown: {{ .Values.shutdown }} - {{- end }} - metadata: - labels: - helm.sh/chart: {{ include "crunchy-postgres.chart" . }} - app.kubernetes.io/name: {{ include "crunchy-postgres.name" . }} - app.kubernetes.io/instance: {{ include "crunchy-postgres.fullname" . }} - {{- if .Chart.AppVersion }} - app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} - {{- end }} - app.kubernetes.io/managed-by: {{ .Release.Service }} - {{- range $key, $value := .Values.labels }} - {{ $key }}: {{ $value | quote }} - {{- end }} - app.kubernetes.io/component: "database" - {{ if .Values.crunchyImage }} - image: {{ .Values.crunchyImage }} - {{ end }} - imagePullPolicy: {{.Values.imagePullPolicy}} - postgresVersion: {{ .Values.postgresVersion }} - {{ if .Values.postGISVersion }} - postGISVersion: {{ .Values.postGISVersion | quote }} - {{ end }} - postgresVersion: {{ .Values.postgresVersion }} - - {{ if .Values.pgmonitor.enabled }} - - monitoring: - pgmonitor: - # this stuff is for the "exporter" container in the "postgres-cluster-ha" set of pods - exporter: - {{ if .Values.pgmonitor.exporter.image}} - image: {{ .Values.pgmonitor.exporter.image}} - {{ end }} - resources: - requests: - cpu: {{ .Values.pgmonitor.exporter.requests.cpu }} - memory: {{ .Values.pgmonitor.exporter.requests.memory }} - limits: - cpu: {{ .Values.pgmonitor.exporter.limits.cpu }} - memory: {{ .Values.pgmonitor.exporter.limits.memory }} - - {{ end }} - - instances: - - name: {{ .Values.instances.name }} - replicas: {{ .Values.instances.replicas }} - resources: - requests: - cpu: {{ .Values.instances.requests.cpu }} - memory: {{ .Values.instances.requests.memory }} - sidecars: - replicaCertCopy: - resources: - requests: - cpu: {{ .Values.instances.replicaCertCopy.requests.cpu }} - memory: {{ .Values.instances.replicaCertCopy.requests.memory }} - limits: - cpu: {{ .Values.instances.replicaCertCopy.limits.cpu }} - memory: {{ .Values.instances.replicaCertCopy.limits.memory }} - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: {{ .Values.instances.dataVolumeClaimSpec.storage }} - storageClassName: {{ .Values.instances.dataVolumeClaimSpec.storageClassName }} - affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 1 - podAffinityTerm: - topologyKey: topology.kubernetes.io/zone - labelSelector: - matchLabels: - postgres-operator.crunchydata.com/cluster: - {{ template "crunchy-postgres.fullname" . }} - postgres-operator.crunchydata.com/instance-set: {{ .Values.instances.name }}-ha - - users: - - name: {{ template "crunchy-postgres.fullname" . }} - databases: - - {{ template "crunchy-postgres.fullname" . }} - options: "CREATEROLE" - - name: postgres - databases: - - {{ template "crunchy-postgres.fullname" . }} - - {{ if .Values.dataSource.enabled }} - dataSource: - pgbackrest: - configuration: - - secret: - name: {{ .Values.dataSource.secretName }} - global: - repo2-s3-uri-style: {{ .Values.dataSource.repo.s3.uriStyle | quote }} - repo2-path: {{ .Values.dataSource.repo.path }} - repo: - name: {{ .Values.dataSource.repo.name }} - s3: - bucket: {{ .Values.dataSource.repo.s3.bucket }} - endpoint: {{ .Values.dataSource.repo.s3.endpoint }} - region: {{ .Values.dataSource.repo.s3.region }} - stanza: {{ .Values.dataSource.stanza }} - {{ end }} - - backups: - pgbackrest: - {{ if .Values.pgBackRest.image }} - image: {{ .Values.pgBackRest.image }} - {{ end }} - {{- if .Values.pgBackRest.s3.enabled }} - configuration: - - secret: - name: {{ .Values.pgBackRest.s3.s3Secret }} - {{- end }} - global: - # Support both PVC and s3 backups - repo1-retention-full: {{ .Values.pgBackRest.retention | quote }} - repo1-retention-full-type: {{ .Values.pgBackRest.retentionFullType }} - repo1-retention-archive: {{ .Values.pgBackRest.retentionArchive | quote }} - repo1-retention-archive-type: {{ .Values.pgBackRest.retentionArchiveType }} - {{- if .Values.pgBackRest.s3.enabled }} - repo2-retention-full: {{ .Values.pgBackRest.retentionS3 | quote }} - repo2-retention-full-type: {{ .Values.pgBackRest.retentionFullTypeS3 }} - repo2-path: {{ .Values.pgBackRest.s3.s3Path }} - repo2-s3-uri-style: {{ .Values.pgBackRest.s3.s3UriStyle }} - {{- end }} - repos: - # hardcoding repo1 until we solution allowing multiple repos - - name: repo1 - schedules: - full: {{ .Values.pgBackRest.repos.schedules.full }} - incremental: {{ .Values.pgBackRest.repos.schedules.incremental }} - volume: - volumeClaimSpec: - accessModes: - - {{ .Values.pgBackRest.repos.volume.accessModes }} - resources: - requests: - storage: {{ .Values.pgBackRest.repos.volume.storage }} - storageClassName: {{ .Values.pgBackRest.repos.volume.storageClassName }} - {{- if .Values.pgBackRest.s3.enabled }} - - name: repo2 - schedules: - full: {{ if .Values.pgBackRest.s3.fullSchedule }}{{ .Values.pgBackRest.s3.fullSchedule }}{{ else }}{{ .Values.pgBackRest.repos.schedules.full }}{{ end }} - incremental: {{ if .Values.pgBackRest.s3.incrementalSchedule }}{{ .Values.pgBackRest.s3.incrementalSchedule }}{{ else }}{{ .Values.pgBackRest.repos.schedules.incremental }}{{ end }} - s3: - bucket: {{ .Values.pgBackRest.s3.bucket }} - endpoint: {{ .Values.pgBackRest.s3.endpoint }} - region: {{ .Values.pgBackRest.s3.region }} - {{- end }} - # this stuff is for the "pgbackrest" container (the only non-init container) in the "postgres-crunchy-repo-host" pod - repoHost: - resources: - requests: - cpu: {{ .Values.pgBackRest.repoHost.requests.cpu }} - memory: {{ .Values.pgBackRest.repoHost.requests.memory }} - limits: - cpu: {{ .Values.pgBackRest.repoHost.limits.cpu }} - memory: {{ .Values.pgBackRest.repoHost.limits.memory }} - sidecars: - # this stuff is for the "pgbackrest" container in the "postgres-crunchy-ha" set of pods - pgbackrest: - resources: - requests: - cpu: {{ .Values.pgBackRest.sidecars.requests.cpu }} - memory: {{ .Values.pgBackRest.sidecars.requests.memory }} - limits: - cpu: {{ .Values.pgBackRest.sidecars.limits.cpu }} - memory: {{ .Values.pgBackRest.sidecars.limits.memory }} - pgbackrestConfig: - resources: - requests: - cpu: {{ .Values.pgBackRest.sidecars.requests.cpu }} - memory: {{ .Values.pgBackRest.sidecars.requests.memory }} - limits: - cpu: {{ .Values.pgBackRest.sidecars.limits.cpu }} - memory: {{ .Values.pgBackRest.sidecars.limits.memory }} - standby: - enabled: {{ .Values.standby.enabled }} - repoName: {{ .Values.standby.repoName }} - - patroni: - dynamicConfiguration: - postgresql: - pg_hba: - {{- range .Values.patroni.postgresql.pg_hba }} - - {{ . | quote }} - {{- end }} - parameters: - shared_buffers: {{ .Values.patroni.postgresql.parameters.shared_buffers }} - wal_buffers: {{ .Values.patroni.postgresql.parameters.wal_buffers }} - min_wal_size: {{ .Values.patroni.postgresql.parameters.min_wal_size }} - max_wal_size: {{ .Values.patroni.postgresql.parameters.max_wal_size }} - max_slot_wal_keep_size: {{ .Values.patroni.postgresql.parameters.max_slot_wal_keep_size }} - temp_file_limit: {{ .Values.patroni.postgresql.parameters.temp_file_limit }} - checkpoint_timeout: {{ .Values.patroni.postgresql.parameters.checkpoint_timeout }} - checkpoint_completion_target: {{ .Values.patroni.postgresql.parameters.checkpoint_completion_target }} - - proxy: - pgBouncer: - config: - global: - client_tls_sslmode: disable - {{ if .Values.proxy.pgBouncer.image }} - image: {{ .Values.proxy.pgBouncer.image }} - {{ end }} - replicas: {{ .Values.proxy.pgBouncer.replicas }} - # these resources are for the "pgbouncer" container in the "postgres-crunchy-ha-pgbouncer" set of pods - # there is a sidecar in these pods which are not mentioned here, but the requests/limits are teeny weeny by default so no worries there. - resources: - requests: - cpu: {{ .Values.proxy.pgBouncer.requests.cpu }} - memory: {{ .Values.proxy.pgBouncer.requests.memory }} - limits: - cpu: {{ .Values.proxy.pgBouncer.limits.cpu }} - memory: {{ .Values.proxy.pgBouncer.limits.memory }} - affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - weight: 1 - podAffinityTerm: - topologyKey: topology.kubernetes.io/zone - labelSelector: - matchLabels: - postgres-operator.crunchydata.com/cluster: - {{ .Values.instances.name }} - postgres-operator.crunchydata.com/role: pgbouncer diff --git a/database/crunchy-postgres/templates/_helpers.tpl b/database/crunchy-postgres/templates/_helpers.tpl deleted file mode 100644 index 1a758b08e..000000000 --- a/database/crunchy-postgres/templates/_helpers.tpl +++ /dev/null @@ -1,66 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "crunchy-postgres.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). -If release name contains chart name it will be used as a full name. -*/}} -{{- define "crunchy-postgres.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "crunchy-postgres.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "crunchy-postgres.labels" -}} -helm.sh/chart: {{ include "crunchy-postgres.chart" . }} -{{ include "crunchy-postgres.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- range $key, $value := .Values.labels }} -{{ $key }}: {{ $value | quote }} -{{- end }} -app.kubernetes.io/component: "database" -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "crunchy-postgres.selectorLabels" -}} -app.kubernetes.io/name: {{ include "crunchy-postgres.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "crunchy-postgres.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "crunchy-postgres.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} diff --git a/database/crunchy-postgres/templates/_s3.tpl b/database/crunchy-postgres/templates/_s3.tpl deleted file mode 100644 index 9f71811f7..000000000 --- a/database/crunchy-postgres/templates/_s3.tpl +++ /dev/null @@ -1,18 +0,0 @@ -{{/* Allow for S3 secret information to be stored in a Secret */}} -{{- define "postgres.s3" }} -[global] -{{- if .s3 }} - {{- if .s3.key }} -repo{{ add .index 1 }}-s3-key={{ .s3.key }} - {{- end }} - {{- if .s3.keySecret }} -repo{{ add .index 1 }}-s3-key-secret={{ .s3.keySecret }} - {{- end }} - {{- if .s3.keyType }} -repo{{ add .index 1 }}-s3-key-type={{ .s3.keyType }} - {{- end }} - {{- if .s3.encryptionPassphrase }} -repo{{ add .index 1 }}-cipher-pass={{ .s3.encryptionPassphrase }} - {{- end }} -{{- end }} -{{ end }} \ No newline at end of file diff --git a/database/crunchy-postgres/templates/data-restore-configmap.yaml b/database/crunchy-postgres/templates/data-restore-configmap.yaml deleted file mode 100644 index d60ad8ea6..000000000 --- a/database/crunchy-postgres/templates/data-restore-configmap.yaml +++ /dev/null @@ -1,35 +0,0 @@ -{{- if .Values.dataRestore.enabled }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "crunchy-postgres.fullname" . }}-data-restore-config - labels: - helm.sh/chart: {{ include "crunchy-postgres.chart" . }} - app.kubernetes.io/name: {{ include "crunchy-postgres.name" . }} - app.kubernetes.io/instance: {{ include "crunchy-postgres.fullname" . }} - {{- if .Chart.AppVersion }} - app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} - {{- end }} - app.kubernetes.io/managed-by: {{ .Release.Service }} - {{- range $key, $value := .Values.labels }} - {{ $key }}: {{ $value | quote }} - {{- end }} - app.kubernetes.io/component: "data-restore-config" -data: - pgbackrest.conf: | - [global] - repo{{ .Values.dataRestore.repo.name | replace "repo" "" }}-type=s3 - repo{{ .Values.dataRestore.repo.name | replace "repo" "" }}-s3-bucket={{ .Values.dataRestore.repo.bucket }} - repo{{ .Values.dataRestore.repo.name | replace "repo" "" }}-s3-endpoint={{ .Values.dataRestore.repo.endpoint }} - repo{{ .Values.dataRestore.repo.name | replace "repo" "" }}-s3-region={{ .Values.dataRestore.repo.s3.region | default "not-used" }} - repo{{ .Values.dataRestore.repo.name | replace "repo" "" }}-path={{ .Values.dataRestore.repo.path }} - repo{{ .Values.dataRestore.repo.name | replace "repo" "" }}-s3-uri-style={{ .Values.dataRestore.repo.s3.uriStyle | default "path" }} - log-level-console=info - log-level-file=debug - - [{{ .Values.dataRestore.stanza }}] - pg1-host={{ if .Values.dataRestore.target.clusterName }}{{ .Values.dataRestore.target.clusterName }}{{ else }}{{ include "crunchy-postgres.fullname" . }}{{ end }}-primary.{{ .Release.Namespace }}.svc.cluster.local - pg1-port=5432 - pg1-user=postgres - pg1-database={{ .Values.dataRestore.target.database }} -{{- end }} diff --git a/database/crunchy-postgres/templates/data-restore-cronjob.yaml b/database/crunchy-postgres/templates/data-restore-cronjob.yaml deleted file mode 100644 index b22a6b2fa..000000000 --- a/database/crunchy-postgres/templates/data-restore-cronjob.yaml +++ /dev/null @@ -1,190 +0,0 @@ -{{- if .Values.dataRestore.enabled }} -apiVersion: batch/v1 -kind: CronJob -metadata: - name: {{ include "crunchy-postgres.fullname" . }}-data-restore - annotations: - app.openshift.io/connects-to: {{ include "crunchy-postgres.fullname" . }} - app.openshift.io/vcs-ref: main - app.openshift.io/runtime-namespace: {{ .Release.Namespace }} - app.openshift.io/runtime: postgresql - labels: - helm.sh/chart: {{ include "crunchy-postgres.chart" . }} - app.kubernetes.io/name: {{ include "crunchy-postgres.name" . }} - app.kubernetes.io/instance: {{ include "crunchy-postgres.fullname" . }} - {{- if .Chart.AppVersion }} - app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} - {{- end }} - app.kubernetes.io/managed-by: {{ .Release.Service }} - {{- range $key, $value := .Values.labels }} - {{ $key }}: {{ $value | quote }} - {{- end }} - app.kubernetes.io/component: "database" -spec: - schedule: {{ .Values.dataRestore.schedule | quote }} - successfulJobsHistoryLimit: {{ .Values.dataRestore.successfulJobsHistoryLimit }} - failedJobsHistoryLimit: {{ .Values.dataRestore.failedJobsHistoryLimit }} - jobTemplate: - metadata: - labels: - helm.sh/chart: {{ include "crunchy-postgres.chart" . }} - app.kubernetes.io/name: {{ include "crunchy-postgres.name" . }} - app.kubernetes.io/instance: {{ include "crunchy-postgres.fullname" . }} - {{- if .Chart.AppVersion }} - app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} - {{- end }} - app.kubernetes.io/managed-by: {{ .Release.Service }} - {{- range $key, $value := .Values.labels }} - {{ $key }}: {{ $value | quote }} - {{- end }} - app.kubernetes.io/component: "database" - spec: - template: - metadata: - labels: - helm.sh/chart: {{ include "crunchy-postgres.chart" . }} - app.kubernetes.io/name: {{ include "crunchy-postgres.name" . }} - app.kubernetes.io/instance: {{ include "crunchy-postgres.fullname" . }} - {{- if .Chart.AppVersion }} - app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} - {{- end }} - app.kubernetes.io/managed-by: {{ .Release.Service }} - {{- range $key, $value := .Values.labels }} - {{ $key }}: {{ $value | quote }} - {{- end }} - app.kubernetes.io/component: "database" - spec: - restartPolicy: {{ .Values.dataRestore.restartPolicy }} - containers: - - name: pgbackrest-restore - image: {{ .Values.dataRestore.image }} - command: ["/bin/bash"] - args: - - "-c" - - | - set -e - echo "=== Change Data Capture with S3 Restore Started ===" - echo "Timestamp: $(date)" - echo "Namespace: $NAMESPACE" - echo "Pod: $PODNAME" - - # Set connection parameters - LOCAL_DB_HOST="$PGBACKREST_DB_HOST" - LOCAL_DB_PORT="$PGBACKREST_DB_PORT" - - echo "Target Database: $LOCAL_DB_HOST:$LOCAL_DB_PORT" - echo "S3 Bucket: {{ .Values.dataRestore.repo.bucket }}" - echo "S3 Path: {{ .Values.dataRestore.repo.path }}" - echo "Stanza: $PGBACKREST_STANZA" - echo "Repo: $PGBACKREST_REPO" - - # Merge configuration files to create a complete pgbackrest.conf - echo "=== Setting up pgBackRest Configuration ===" - echo "Creating merged configuration file..." - cat /etc/pgbackrest/pgbackrest.conf > /tmp/pgbackrest.conf - echo "" >> /tmp/pgbackrest.conf - echo "# S3 Credentials from secret" >> /tmp/pgbackrest.conf - cat /etc/pgbackrest/s3.conf >> /tmp/pgbackrest.conf - echo "Configuration created successfully" - - # Set the environment variable to use our merged config - export PGBACKREST_CONFIG=/tmp/pgbackrest.conf - - # Step 1: Query S3 for latest backup info (using pgbackrest info) - echo "=== Step 1: Checking S3 Backup Information ===" - echo "Querying S3 for latest backup..." - - # Use pgbackrest info to check what's available in S3 - - echo "Available backups in S3:" - PGBACKREST_INFO_OUTPUT=$(pgbackrest info --stanza="$PGBACKREST_STANZA" --repo="$PGBACKREST_REPO" --log-level-console=info 2>&1) - echo "$PGBACKREST_INFO_OUTPUT" - - if echo "$PGBACKREST_INFO_OUTPUT" | grep -q "status: error"; then - echo "ERROR: pgBackRest reported an error status. Check S3 credentials and permissions." - exit 1 - fi - - if echo "$PGBACKREST_INFO_OUTPUT" | grep -q "SignatureDoesNotMatch"; then - echo "ERROR: S3 authentication failed (SignatureDoesNotMatch). Check your Secret Access Key." - exit 1 - fi - - echo "✓ S3 backup information retrieved" - - # Step 2: Implement change data capture logic - echo "=== Step 2: Change Data Capture Operations ===" - echo "Note: Full restore cannot be performed on a running cluster" - echo "Implementing incremental sync approach instead..." - - # Wait for database to be ready - echo "Checking database connectivity..." - for i in {1..10}; do - if pg_isready -h "$LOCAL_DB_HOST" -p "$LOCAL_DB_PORT" 2>/dev/null; then - echo "✓ Database is ready" - break - fi - echo "Waiting for database... ($i/10)" - sleep 5 - done - - # Simulate CDC operations that would use the S3 backup data - echo "CDC Operations would:" - echo "1. Compare current database state with latest S3 backup" - echo "2. Identify data differences and changes" - echo "3. Apply incremental updates to maintain consistency" - echo "4. Update tracking tables with sync status" - - # Update last sync timestamp - CURRENT_TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') - echo "=== Restore and CDC Completed Successfully ===" - echo "Completion timestamp: $CURRENT_TIMESTAMP" - echo "=== Change Data Capture with S3 Restore Completed ===" - env: - - name: NAMESPACE - value: {{ .Release.Namespace | quote }} - - name: PODNAME - valueFrom: - fieldRef: - fieldPath: metadata.name - - name: PGBACKREST_STANZA - value: {{ .Values.dataRestore.stanza | quote }} - - name: PGBACKREST_REPO - value: {{ .Values.dataRestore.repo.name | replace "repo" "" | quote }} - - name: PGBACKREST_DB_HOST - value: {{ if .Values.dataRestore.target.clusterName }}{{ .Values.dataRestore.target.clusterName }}{{ else }}{{ include "crunchy-postgres.fullname" . }}{{ end }}-primary.{{ .Release.Namespace }}.svc.cluster.local - - name: PGBACKREST_DB_PORT - value: "5432" - - name: PGUSER - value: "postgres" - - name: PGDATABASE - value: "postgres" - - name: CDC_JOB_NAME - value: {{ include "crunchy-postgres.fullname" . }}-data-restore - - name: CDC_SCHEDULE - value: {{ .Values.dataRestore.schedule | quote }} - resources: - requests: - cpu: {{ .Values.dataRestore.resources.requests.cpu }} - memory: {{ .Values.dataRestore.resources.requests.memory }} - limits: - cpu: {{ .Values.dataRestore.resources.limits.cpu }} - memory: {{ .Values.dataRestore.resources.limits.memory }} - volumeMounts: - - name: pgbackrest-config - mountPath: /etc/pgbackrest - readOnly: true - - name: tmp - mountPath: /tmp - volumes: - - name: pgbackrest-config - projected: - sources: - - secret: - name: {{ .Values.dataRestore.secretName }} - - configMap: - name: {{ include "crunchy-postgres.fullname" . }}-data-restore-config - optional: true - - name: tmp - emptyDir: {} -{{- end }} diff --git a/database/crunchy-postgres/templates/data-restore-secret.yaml b/database/crunchy-postgres/templates/data-restore-secret.yaml deleted file mode 100644 index e2e2c0803..000000000 --- a/database/crunchy-postgres/templates/data-restore-secret.yaml +++ /dev/null @@ -1,16 +0,0 @@ -{{- if and .Values.dataRestore.enabled .Values.dataRestore.createS3Secret }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ .Values.dataRestore.secretName | default "dev-s3-restore" }} - namespace: {{ .Release.Namespace }} - labels: - {{- include "crunchy-postgres.labels" . | nindent 4 }} -type: Opaque -stringData: - # Same format as pgBackRest secret - using s3.conf key name to match - s3.conf: | - [global] - repo2-s3-key={{ .Values.dataRestore.s3.key }} - repo2-s3-key-secret={{ .Values.dataRestore.s3.keySecret }} -{{- end }} diff --git a/database/crunchy-postgres/templates/s3Secret.yaml b/database/crunchy-postgres/templates/s3Secret.yaml deleted file mode 100644 index 5c1aef224..000000000 --- a/database/crunchy-postgres/templates/s3Secret.yaml +++ /dev/null @@ -1,11 +0,0 @@ -{{- if and .Values.pgBackRest.s3.enabled .Values.pgBackRest.s3.createS3Secret }} -apiVersion: v1 -kind: Secret -metadata: - name: {{ .Values.pgBackRest.s3.s3Secret }} -type: Opaque -data: - {{- $args := dict "s3" .Values.pgBackRest.s3 "index" 1 }} - s3.conf: |- - {{ include "postgres.s3" $args | b64enc }} -{{- end }} \ No newline at end of file diff --git a/database/crunchy-postgres/values.yaml b/database/crunchy-postgres/values.yaml deleted file mode 100644 index 78ccf662c..000000000 --- a/database/crunchy-postgres/values.yaml +++ /dev/null @@ -1,196 +0,0 @@ -fullnameOverride: crunchy-postgres - -# Set this to true for OpenShift deployments to avoid incompatible securityContext values -openshift: true - -labels: - app.kubernetes.io/part-of: crunchydb-postgres - -crunchyImage: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default -#crunchyImage: artifacts.developer.gov.bc.ca/bcgov-docker-local/crunchy-postgres-gis:ubi8-15.2-3.3-0 # use this image for POSTGIS -postgresVersion: 16 -#postGISVersion: '3.3' # use this version of POSTGIS. both crunchyImage and this property needs to have valid values for POSTGIS to be enabled. -imagePullPolicy: IfNotPresent - -# enable to bootstrap a standby cluster from backup. Then disable to promote this standby to primary -standby: - enabled: false - # If you want to recover from PVC, use repo1. If you want to recover from S3, use repo2 - repoName: repo1 - -instances: - name: ha # high availability - replicas: 2 - dataVolumeClaimSpec: - storage: 512Mi - storageClassName: netapp-block-standard - requests: - cpu: 10m - memory: 256Mi - replicaCertCopy: - requests: - cpu: 1m - memory: 32Mi - limits: - cpu: 50m - memory: 64Mi - -# If we need to restore the cluster from a backup, we need to set the following values -# assuming restore from repo2 (s3), adjust as needed if your S3 repo is different -dataSource: - enabled: false - # should have the same name and contain the same keys as the pgbackrest secret - secretName: s3-pgbackrest - repo: - name: repo2 - path: "/habackup" - s3: - bucket: "bucketName" - endpoint: "https://sector.objectstore.gov.bc.ca" - region: "not-used" - uriStyle: "path" - stanza: db - -pgBackRest: - image: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default - # If retention-full-type set to 'count' then the oldest backups will expire when the number of backups reach the number defined in retention - # If retention-full-type set to 'time' then the number defined in retention will take that many days worth of full backups before expiration - retention: "2" # Ideally a number to keep backups for 2 working days - retentionS3: "30" # Ideally a larger number such as backups for 30 days - retentionFullType: count # Type of retention for full backups - retentionFullTypeS3: time # Type of retention for full backups - retentionArchive: "2" # Number of backups worth of continuous WAL to retain - retentionArchiveType: full # Type of retention for WAL archives - repos: - schedules: - full: 0 6 * * 0 # Full backup every Sunday at 10:00 PM PST - incremental: 15 */8 * * * # Incremental every 8 hours - volume: - accessModes: "ReadWriteOnce" - storage: 256Mi - storageClassName: netapp-file-backup - repoHost: - requests: - cpu: 1m - memory: 64Mi - limits: - cpu: 50m - memory: 128Mi - sidecars: - requests: - cpu: 1m - memory: 64Mi - limits: - cpu: 50m - memory: 128Mi - s3: - enabled: true - createS3Secret: true - # the s3 secret name - s3Secret: s3-pgbackrest - # the path start with /, it will be created under bucket if it doesn't exist - s3Path: "/habackup" - # s3UriStyle is host or path - s3UriStyle: path - # bucket specifies the S3 bucket to use, - bucket: "bucketName" - # endpoint specifies the S3 endpoint to use. - endpoint: "https://sector.objectstore.gov.bc.ca" - # region specifies the S3 region to use. If your S3 storage system does not - # use "region", fill this in with a random value. - region: "not-used" - # key is the S3 key. This is stored in a Secret. - # Please DO NOT push this value to GitHub - key: "s3keyValue" - # keySecret is the S3 key secret. This is stored in a Secret. - # Please DO NOT push this value to GitHub - keySecret: "s3SecretValue" - # set the default schedule to avoid conflicts - fullSchedule: 30 5 * * 0 # Full backup every Monday at 9:30 PM PST - incrementalSchedule: 45 */8 * * * # Incremental every 8 hours - -patroni: - postgresql: - pg_hba: - - "local all postgres trust" # trust local system socket connections user postgres - - "host all all 127.0.0.1/32 trust" # trust IPv4 local connections includes port forwarding - - "host all all ::1/128 trust" # trust IPv6 local connections includes port forwarding - - "host all all 10.0.0.0/8 md5" # Allow any users to connect to any database from 10.x.x.x private subnet range if password is correctly supplied - parameters: - shared_buffers: 256MB # default is 128MB; a good tuned default for shared_buffers is 25% of the memory allocated to the pod - wal_buffers: "-1" # this can be set to -1 to automatically set as 1/32 of shared_buffers or 64kB, whichever is larger - min_wal_size: 64MB # Sets the minimum size to shrink the WAL files to - max_wal_size: 256MB # default is 1GB make sure the mounted volume is large enough for the logging - max_slot_wal_keep_size: 256MB # default is -1, allowing unlimited wal growth when replicas fall behind - temp_file_limit: 512MB # Prevent temp files from filling PVC - checkpoint_timeout: 15min # Reduce checkpoint frequency - checkpoint_completion_target: 0.9 # Smooth checkpointing - -proxy: - pgBouncer: - image: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default - replicas: 2 - requests: - cpu: 1m - memory: 64Mi - limits: - cpu: 50m - memory: 128Mi - -# Postgres Cluster resource values: -pgmonitor: - enabled: false - exporter: - image: # it's not necessary to specify an image as the images specified in the Crunchy Postgres Operator will be pulled by default - requests: - cpu: 1m - memory: 64Mi - limits: - cpu: 50m - memory: 128Mi - -# Data restore cronjob configuration - reuses dataSource and pgBackRest.s3 patterns -dataRestore: - enabled: false - createS3Secret: true - schedule: "0 2 * * *" # Run every day at 2 AM - image: "artifacts.developer.gov.bc.ca/bcgov-docker-local/crunchy-pgbackrest:ubi8-2.53.1-0" - secretName: s3-pgbackrest - repo: - name: repo2 - path: "/habackup" - bucket: "bucketName" - endpoint: "https://sector.objectstore.gov.bc.ca" - region: "not-used" - uriStyle: "path" - stanza: db - # S3 credentials for data restore (only used if createS3Secret: true) - s3: - # key is the S3 key. This is stored in a Secret. - # Please DO NOT push this value to GitHub - key: "s3keyValue" - # keySecret is the S3 key secret. This is stored in a Secret. - # Please DO NOT push this value to GitHub - keySecret: "s3SecretValue" - # Target database configuration - target: - # The PostgreSQL cluster name to restore into (defaults to current cluster if empty) - clusterName: "" - # Database name to restore - database: "postgres" - # Resource limits for the cronjob - resources: - requests: - cpu: 100m - memory: 256Mi - limits: - cpu: 500m - memory: 512Mi - # Job settings - successfulJobsHistoryLimit: 3 - failedJobsHistoryLimit: 1 - restartPolicy: OnFailure - # Additional pgbackrest arguments - additionalArgs: [] - # - "--log-level-console=debug" - # - "--process-max=2" diff --git a/database/scripts/metabase-setup-database-readonly.sql b/database/scripts/metabase-setup-database-readonly.sql deleted file mode 100644 index daf367a1b..000000000 --- a/database/scripts/metabase-setup-database-readonly.sql +++ /dev/null @@ -1,98 +0,0 @@ --- This script ensures that the metabase_readonly role has the necessary CONNECT, USAGE, and SELECT privileges --- on the database, schemas, tables, and sequences. It also sets default privileges for any new tables and sequences --- created in the specified schemas. This should allow the metabase_readonly role to access the schemas and tables --- with read-only permissions -DO $$ -DECLARE - db_name TEXT; - schema TEXT; - schema_list TEXT[] := ARRAY['public', 'Flex', 'Notifications', 'Payments', 'Reporting']; - existing_schemas TEXT := ''; -BEGIN - -- Get the name of the current database - SELECT current_database() INTO db_name; - - -- Grant CONNECT privilege on the database - EXECUTE format('GRANT CONNECT ON DATABASE %I TO metabase_readonly;', db_name); - RAISE NOTICE 'Granted CONNECT on database % to role metabase_readonly', db_name; - - -- List schemas in the current database - RAISE NOTICE 'Listing schemas in the current database %:', db_name; - FOREACH schema IN ARRAY schema_list LOOP - IF EXISTS (SELECT 1 FROM information_schema.schemata s WHERE s.schema_name = schema) THEN - existing_schemas := existing_schemas || schema || ', '; - END IF; - END LOOP; - - -- Remove the trailing comma and space - IF existing_schemas <> '' THEN - existing_schemas := substring(existing_schemas FROM 1 FOR length(existing_schemas) - 2); - END IF; - - RAISE NOTICE 'Schemas in the current database %: %', db_name, existing_schemas; - - -- Grant schema usage and set default privileges for metabase_readonly - FOREACH schema IN ARRAY schema_list LOOP - IF EXISTS (SELECT 1 FROM information_schema.schemata s WHERE s.schema_name = schema) THEN - EXECUTE format('GRANT USAGE ON SCHEMA %I TO metabase_readonly;', schema); - RAISE NOTICE 'Granted USAGE on schema % to role metabase_readonly', schema; - - -- Grant SELECT on all existing tables in the schema - EXECUTE format('GRANT SELECT ON ALL TABLES IN SCHEMA %I TO metabase_readonly;', schema); - RAISE NOTICE 'Granted SELECT on all tables in schema % to role metabase_readonly', schema; - - -- Grant USAGE and SELECT on all sequences in the schema - EXECUTE format('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA %I TO metabase_readonly;', schema); - RAISE NOTICE 'Granted USAGE and SELECT on all sequences in schema % to role metabase_readonly', schema; - - -- Set default privileges for metabase_readonly - EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT SELECT ON TABLES TO metabase_readonly;', schema); - EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT USAGE, SELECT ON SEQUENCES TO metabase_readonly;', schema); - RAISE NOTICE 'Set default privileges for role metabase_readonly in schema %', schema; - ELSE - RAISE NOTICE 'Schema % does not exist in the current database', schema; - END IF; - END LOOP; -END $$; - --- Combined Query to List Schema Privileges and Default Privileges for All Schemas, Sorted by Schema Name -WITH schema_privileges AS ( - SELECT - 'SCHEMA' AS object_type, - nspname AS schema, - pg_catalog.pg_get_userbyid(nspowner) AS owner, - array_agg(acl) AS privileges - FROM - pg_namespace - LEFT JOIN - pg_roles ON pg_roles.oid = pg_namespace.nspowner - LEFT JOIN - unnest(nspacl) AS acl ON true - WHERE - nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema' - GROUP BY - nspname, nspowner -), -default_privileges AS ( - SELECT - CASE defaclobjtype - WHEN 'r' THEN 'TABLE' - WHEN 'S' THEN 'SEQUENCE' - WHEN 'f' THEN 'FUNCTION' - WHEN 'T' THEN 'TYPE' - END AS object_type, - nspname AS schema, - pg_catalog.pg_get_userbyid(defaclrole) AS role, - defaclacl AS privileges - FROM - pg_default_acl - JOIN - pg_namespace ON pg_namespace.oid = pg_default_acl.defaclnamespace - WHERE - defaclobjtype IN ('r', 'S', 'f', 'T') - AND nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema' -) -SELECT * FROM schema_privileges -UNION ALL -SELECT * FROM default_privileges -ORDER BY schema; \ No newline at end of file diff --git a/database/scripts/metabase-setup-database-readwrite.sql b/database/scripts/metabase-setup-database-readwrite.sql deleted file mode 100644 index b7156b55f..000000000 --- a/database/scripts/metabase-setup-database-readwrite.sql +++ /dev/null @@ -1,71 +0,0 @@ --- This script ensures that the metabase_readwrite role has the necessary CONNECT, USAGE, SELECT, INSERT, UPDATE, and DELETE privileges --- on the public schema. It also sets default privileges for any new tables and sequences created in the public schema. - -DO $$ -DECLARE - db_name TEXT := 'metabaseuploaddb'; - schema TEXT := 'public'; -BEGIN - -- Grant CONNECT and TEMPORARY on the database to metabase_readwrite - EXECUTE format('GRANT CONNECT, TEMPORARY ON DATABASE %I TO metabase_readwrite;', db_name); - RAISE NOTICE 'Granted CONNECT, TEMPORARY on database % to role metabase_readwrite', db_name; - - -- Grant USAGE and CREATE on the public schema to metabase_readwrite - EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO metabase_readwrite;', schema); - RAISE NOTICE 'Granted USAGE, CREATE on schema % to role metabase_readwrite', schema; - - -- Grant SELECT, INSERT, UPDATE, DELETE on all existing tables in the public schema - EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA %I TO metabase_readwrite;', schema); - RAISE NOTICE 'Granted SELECT, INSERT, UPDATE, DELETE on all tables in schema % to role metabase_readwrite', schema; - - -- Grant USAGE and SELECT, UPDATE on all sequences in the public schema - EXECUTE format('GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA %I TO metabase_readwrite;', schema); - RAISE NOTICE 'Granted USAGE, SELECT, UPDATE on all sequences in schema % to role metabase_readwrite', schema; - - -- Set default privileges for metabase_readwrite - EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO metabase_readwrite;', schema); - EXECUTE format('ALTER DEFAULT PRIVILEGES IN SCHEMA %I GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO metabase_readwrite;', schema); - RAISE NOTICE 'Set default privileges for role metabase_readwrite in schema %', schema; -END $$; - --- Combined Query to List Schema Privileges and Default Privileges for All Schemas, Sorted by Schema Name -WITH schema_privileges AS ( - SELECT - 'SCHEMA' AS object_type, - nspname AS schema, - pg_catalog.pg_get_userbyid(nspowner) AS owner, - array_agg(acl) AS privileges - FROM - pg_namespace - LEFT JOIN - pg_roles ON pg_roles.oid = pg_namespace.nspowner - LEFT JOIN - unnest(nspacl) AS acl ON true - WHERE - nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema' - GROUP BY - nspname, nspowner -), -default_privileges AS ( - SELECT - CASE defaclobjtype - WHEN 'r' THEN 'TABLE' - WHEN 'S' THEN 'SEQUENCE' - WHEN 'f' THEN 'FUNCTION' - WHEN 'T' THEN 'TYPE' - END AS object_type, - nspname AS schema, - pg_catalog.pg_get_userbyid(defaclrole) AS role, - defaclacl AS privileges - FROM - pg_default_acl - JOIN - pg_namespace ON pg_namespace.oid = pg_default_acl.defaclnamespace - WHERE - defaclobjtype IN ('r', 'S', 'f', 'T') - AND nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema' -) -SELECT * FROM schema_privileges -UNION ALL -SELECT * FROM default_privileges -ORDER BY schema; \ No newline at end of file diff --git a/database/scripts/metabase-setup-metabaseuploaddb.sql b/database/scripts/metabase-setup-metabaseuploaddb.sql deleted file mode 100644 index f1069148c..000000000 --- a/database/scripts/metabase-setup-metabaseuploaddb.sql +++ /dev/null @@ -1,76 +0,0 @@ --- This script sets up the metabaseuploaddb with the necessary privileges for the metabase_dbuser role. -DO $$ -DECLARE - db_name TEXT := 'metabaseuploaddb'; -BEGIN - -- Check if the database exists and print the appropriate message - IF NOT EXISTS (SELECT FROM pg_database WHERE datname = db_name) THEN - RAISE NOTICE 'Database does not exist. You need to create it manually: CREATE DATABASE %;', db_name; - ELSE - RAISE NOTICE 'Database "%" already exists.', db_name; - END IF; -END $$; - -DO $$ -DECLARE - db_name TEXT := 'metabaseuploaddb'; - schema TEXT := 'public'; -BEGIN - -- Grant ALL PRIVILEGES on the database to metabase_dbuser - EXECUTE format('GRANT ALL PRIVILEGES ON DATABASE %I TO metabase_dbuser;', db_name); - RAISE NOTICE 'Granted ALL PRIVILEGES on database % to role metabase_dbuser', db_name; - - -- Grant ALL on the public schema to metabase_dbuser - EXECUTE format('GRANT ALL ON SCHEMA %I TO metabase_dbuser;', schema); - RAISE NOTICE 'Granted ALL on schema % to role metabase_dbuser', schema; - - -- Alter the database owner to metabase_dbuser - EXECUTE format('ALTER DATABASE %I OWNER TO metabase_dbuser;', db_name); - RAISE NOTICE 'Changed owner of database % to metabase_dbuser', db_name; - - -- Grant USAGE and CREATE on the public schema to metabase_dbuser - EXECUTE format('GRANT USAGE, CREATE ON SCHEMA %I TO metabase_dbuser;', schema); - RAISE NOTICE 'Granted USAGE, CREATE on schema % to role metabase_dbuser', schema; -END $$; - --- Combined Query to List Schema Privileges and Default Privileges for All Schemas, Sorted by Schema Name -WITH schema_privileges AS ( - SELECT - 'SCHEMA' AS object_type, - nspname AS schema, - pg_catalog.pg_get_userbyid(nspowner) AS owner, - array_agg(acl) AS privileges - FROM - pg_namespace - LEFT JOIN - pg_roles ON pg_roles.oid = pg_namespace.nspowner - LEFT JOIN - unnest(nspacl) AS acl ON true - WHERE - nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema' - GROUP BY - nspname, nspowner -), -default_privileges AS ( - SELECT - CASE defaclobjtype - WHEN 'r' THEN 'TABLE' - WHEN 'S' THEN 'SEQUENCE' - WHEN 'f' THEN 'FUNCTION' - WHEN 'T' THEN 'TYPE' - END AS object_type, - nspname AS schema, - pg_catalog.pg_get_userbyid(defaclrole) AS role, - defaclacl AS privileges - FROM - pg_default_acl - JOIN - pg_namespace ON pg_namespace.oid = pg_default_acl.defaclnamespace - WHERE - defaclobjtype IN ('r', 'S', 'f', 'T') - AND nspname NOT LIKE 'pg_%' AND nspname <> 'information_schema' -) -SELECT * FROM schema_privileges -UNION ALL -SELECT * FROM default_privileges -ORDER BY schema; diff --git a/database/scripts/metabase-setup-readme.md b/database/scripts/metabase-setup-readme.md deleted file mode 100644 index d7ccfdbf5..000000000 --- a/database/scripts/metabase-setup-readme.md +++ /dev/null @@ -1,110 +0,0 @@ -## Metabase Read-Only Permissions in PostgreSQL - -The script applies **read-only permissions** to the schemas relevant to Metabase reporting (`public`, `Flex`, `Notifications`, `Payments`). It does the following: -1. **Checks if each schema exists** before granting permissions. -2. **Grants USAGE on schemas** to `metabase_readonly`. -3. **Sets default privileges** for `metabase_readonly`: - - **TABLES:** Grants `SELECT` - - **SEQUENCES:** Grants `USAGE, SELECT` -4. **Lists existing privileges** for schemas, tables, and sequences. - ---- - -## Database Setup Roles - -### 1. Running `metabase-setup-roles.sql` - -This script creates the necessary roles and users for Metabase with read-only and read/write permissions. - -#### Steps: -1. **Create Readonly and Read/Write Group Roles**: - - `metabase_readonly` - - `metabase_readwrite` -2. **Create Users and Assign Them to the Correct Roles**: - - `ugm_readonly` - - `ugt_readonly` - - `ugm_uploads` -3. **Cleanup Roles**: - - Drop unnecessary roles. -4. **Verify Role Assignments**: - - List all custom roles excluding default PostgreSQL roles. -5. **Verify Role Memberships**: - - List role memberships excluding default PostgreSQL roles. - -### 2. Applying `metabase_readonly` to All Tenant Databases - -After running `metabase-setup-roles.sql`, apply the `metabase_readonly` role to all tenant databases to ensure read-only access. - -#### Steps: -1. **Grant CONNECT privilege on the database**. -2. **Grant USAGE on schemas**. -3. **Grant SELECT on all existing tables in the schemas**. -4. **Grant USAGE and SELECT on all sequences in the schemas**. -5. **Set default privileges for `metabase_readonly`**: - - **TABLES:** Grants `SELECT` - - **SEQUENCES:** Grants `USAGE, SELECT` - -### 3. Running `metabase-setup-metabaseuploaddb.sql` - -This script sets up the `metabaseuploaddb` with the necessary privileges for the `metabase_dbuser` role. - -#### Steps: -1. **Grant ALL PRIVILEGES on the database to `metabase_dbuser`**. -2. **Grant ALL on the public schema to `metabase_dbuser`**. -3. **Alter the database owner to `metabase_dbuser`**. -4. **Grant USAGE and CREATE on the public schema to `metabase_dbuser`**. - -### 4. Applying `metabase_readwrite` Role - -After setting up the `metabaseuploaddb`, apply the `metabase_readwrite` role to ensure the necessary privileges. - -#### Steps: -1. **Grant CONNECT and TEMPORARY on the database to `metabase_readwrite`**. -2. **Grant USAGE and CREATE on the public schema to `metabase_readwrite`**. -3. **Grant SELECT, INSERT, UPDATE, DELETE on all existing tables in the public schema**. -4. **Grant USAGE and SELECT, UPDATE on all sequences in the public schema**. -5. **Set default privileges for `metabase_readwrite`**: - - **TABLES:** Grants `SELECT, INSERT, UPDATE, DELETE` - - **SEQUENCES:** Grants `USAGE, SELECT, UPDATE` - ---- - -### Explanation of Query Results -Each row in the output represents a privilege assignment for a specific schema and object type (`SCHEMA`, `TABLE`, `SEQUENCE`). - -#### **Key Terms** -- **`object_type`**: Type of object (SCHEMA, TABLE, SEQUENCE). -- **`schema`**: The schema name. -- **`owner`**: The user who owns the schema. -- **`privileges`**: The privileges assigned in `[role=permissions/owner]` format. - - `r` = SELECT (read) - - `U` = USAGE - - `C` = CREATE (for schemas) - - `rU` = SELECT + USAGE (for sequences) - - `UC` = USAGE + CREATE (for schemas) - -#### **Results Breakdown** -| Object Type | Schema | Owner | Privileges | -|-------------|--------------|-----------|------------| -| **TABLE** | `Flex` | `postgres` | `["metabase_readonly=r/postgres"]` → Read-only access on tables | -| **SCHEMA** | `Flex` | `postgres` | `["postgres=UC/postgres", "metabase_readonly=U/postgres"]` → `metabase_readonly` can use this schema, but not create objects. | -| **SEQUENCE**| `Flex` | `postgres` | `["metabase_readonly=rU/postgres"]` → Read and use sequences. | -| **SCHEMA** | `Notifications` | `postgres` | `["postgres=UC/postgres", "metabase_readonly=U/postgres"]` | -| **SEQUENCE**| `Notifications` | `postgres` | `["metabase_readonly=rU/postgres"]` | -| **TABLE** | `Notifications` | `postgres` | `["metabase_readonly=r/postgres"]` | -| **SCHEMA** | `Payments` | `postgres` | `["postgres=UC/postgres", "metabase_readonly=U/postgres"]` | -| **TABLE** | `Payments` | `postgres` | `["metabase_readonly=r/postgres"]` | -| **SEQUENCE**| `Payments` | `postgres` | `["metabase_readonly=rU/postgres"]` | -| **SCHEMA** | `public` | `pg_database_owner` | `["pg_database_owner=UC/pg_database_owner", "=U/pg_database_owner", "metabase_readonly=U/pg_database_owner"]` | -| **TABLE** | `public` | `postgres` | `["metabase_readonly=r/postgres"]` | -| **SEQUENCE**| `public` | `postgres` | `["metabase_readonly=rU/postgres"]` | - ---- - -### **Key Takeaways** -- `metabase_readonly` **has access to all specified schemas** (`USAGE` granted). -- `metabase_readonly` **can query tables (`SELECT`) but cannot modify them**. -- `metabase_readonly` **can use sequences (`USAGE, SELECT`) but cannot modify them**. -- **No `CREATE` privileges were granted**, ensuring Metabase remains read-only. - -This configuration ensures **secure, repeatable, and limited** readonly PostgreSQL access for Metabase reporting. diff --git a/database/scripts/metabase-setup-roles.sql b/database/scripts/metabase-setup-roles.sql deleted file mode 100644 index 3b0797bfa..000000000 --- a/database/scripts/metabase-setup-roles.sql +++ /dev/null @@ -1,70 +0,0 @@ --- 1. Create Readonly and Read/Write Group Roles -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'metabase_readonly') THEN - CREATE ROLE metabase_readonly NOLOGIN; - END IF; - - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'metabase_readwrite') THEN - CREATE ROLE metabase_readwrite NOLOGIN; - END IF; -END $$; - --- 2. Create Users and Assign Them to the Correct Roles -DO $$ -DECLARE - ugm_readonly_password TEXT := (SELECT string_agg(substring('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' FROM floor(random() * 62 + 1)::int FOR 1), '') FROM generate_series(1, 16)); - ugt_readonly_password TEXT := (SELECT string_agg(substring('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' FROM floor(random() * 62 + 1)::int FOR 1), '') FROM generate_series(1, 16)); - ugm_uploads_password TEXT := (SELECT string_agg(substring('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' FROM floor(random() * 62 + 1)::int FOR 1), '') FROM generate_series(1, 16)); -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ugm_readonly') THEN - EXECUTE format('CREATE ROLE ugm_readonly WITH LOGIN PASSWORD %L INHERIT', ugm_readonly_password); - GRANT metabase_readonly TO ugm_readonly; - RAISE NOTICE 'Role ugm_readonly created and assigned to metabase_readonly successfully. Password: %', ugm_readonly_password; - ELSE - RAISE NOTICE 'Role ugm_readonly already exists.'; - END IF; - - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ugt_readonly') THEN - EXECUTE format('CREATE ROLE ugt_readonly WITH LOGIN PASSWORD %L INHERIT', ugt_readonly_password); - GRANT metabase_readonly TO ugt_readonly; - RAISE NOTICE 'Role ugt_readonly created and assigned to metabase_readonly successfully. Password: %', ugt_readonly_password; - ELSE - RAISE NOTICE 'Role ugt_readonly already exists.'; - END IF; - - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ugm_uploads') THEN - EXECUTE format('CREATE ROLE ugm_uploads WITH LOGIN PASSWORD %L INHERIT', ugm_uploads_password); - GRANT metabase_readwrite TO ugm_uploads; - RAISE NOTICE 'Role ugm_uploads created and assigned to metabase_readwrite successfully. Password: %', ugm_uploads_password; - ELSE - RAISE NOTICE 'Role ugm_uploads already exists.'; - END IF; -END $$; - --- 3. Cleanup Roles -DO $$ -BEGIN - -- Role: metabase_grant_name - DROP ROLE IF EXISTS metabase_grant_name; - -- Role: grant_name - DROP ROLE IF EXISTS grant_name; - -- Role: pg_read_all_data - REVOKE pg_read_all_data FROM metabase_readonly; - REVOKE pg_write_all_data FROM metabase_readwrite; -END $$; - --- 4. Verify Role Assignments --- List all custom roles excluding default PostgreSQL roles -SELECT rolname, rolsuper, rolinherit, rolcreaterole, rolcreatedb, rolcanlogin, rolreplication, rolbypassrls -FROM pg_roles -WHERE rolname NOT LIKE 'pg_%'; - --- 5. Verify Role Memberships: --- List role memberships excluding default PostgreSQL roles -SELECT pg_roles.rolname AS role_name, member.rolname AS member_name -FROM pg_auth_members -JOIN pg_roles ON pg_roles.oid = pg_auth_members.roleid -JOIN pg_roles AS member ON member.oid = pg_auth_members.member -WHERE pg_roles.rolname NOT LIKE 'pg_%' -AND member.rolname NOT LIKE 'pg_%'; \ No newline at end of file diff --git a/database/scripts/unitydb-census-subdivision-script.sql b/database/scripts/unitydb-census-subdivision-script.sql deleted file mode 100644 index ee74246b0..000000000 --- a/database/scripts/unitydb-census-subdivision-script.sql +++ /dev/null @@ -1,19 +0,0 @@ - -DO $$ -DECLARE - json_data jsonb := '[{"CensusSubdivisionName":"Ahahswinis 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Alberni 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Alberni-Clayoquot A","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Alberni-Clayoquot B","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Alberni-Clayoquot C","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Alberni-Clayoquot D","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Alberni-Clayoquot E","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Alberni-Clayoquot F","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Anacla 12","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Clakamucus 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Elhlateese 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Esowista 3","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Hesquiat 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Ittatsoo 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Klehkoot 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Macoah 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Marktosis 15","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Numukamis 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Opitsat 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Port Alberni","Type":"City","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Refuge Cove 6","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Sachsa 4","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Tin Wis 11","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Tofino","Type":"District municipality","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Tsahaheh 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Ucluelet","Type":"District municipality","RegionalDistrictCode":"4"},{"CensusSubdivisionName":"Babine 16","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Babine 25","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Babine 6","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Babine Lake 21B","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Binche 2","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Bulkley-Nechako A","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Bulkley-Nechako B","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Bulkley-Nechako C","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Bulkley-Nechako D","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Bulkley-Nechako E","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Bulkley-Nechako F","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Bulkley-Nechako G","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Burns Lake","Type":"Village","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Burns Lake 18","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Cheslatta 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Duncan Lake 2","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Dzitline Lee 9","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Fort St. James","Type":"District municipality","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Francois Lake 7","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Fraser Lake","Type":"Village","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Granisle","Type":"Village","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Houston","Type":"District municipality","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Jean Baptiste 28","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Laketown 3","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Mission Lands 17","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Nak''azdli","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Nautley (Fort Fraser) 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Nedoats 11","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Noonla 6","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"North Tacla Lake","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Omineca 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Palling 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Poison Creek 17A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Seaspunkut 4","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Skins Lake 16A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Skins Lake 16B","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Smithers","Type":"Town","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Sowchea 3","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Stellaquo (Stella) 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Stony Creek 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Tache 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Tacla Lake (Ferry Landing) 9","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Tadinlay 15","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Tatla West 11","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Tatla''t East 2","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Telkwa","Type":"Village","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Uncha Lake 13A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Vanderhoof","Type":"District municipality","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Williams Prairie Meadow 1A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Woyenne 27","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Ye Koo Che 3","Type":"Indian reserve","RegionalDistrictCode":"26"},{"CensusSubdivisionName":"Becher Bay 1","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Central Saanich","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Cole Bay 3","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Colwood","Type":"City","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"East Saanich 2","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Esquimalt - District municipality","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Esquimalt - Indian reserve","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Galiano Island 9","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Gordon River 2","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Highlands","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Juan de Fuca (Part 1)","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Juan de Fuca (Part 2)","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Langford","Type":"City","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Metchosin","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"New Songhees 1A","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"North Saanich","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Oak Bay","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Saanich","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Saltspring Island","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Sidney","Type":"Town","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Sooke","Type":"District municipality","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"South Saanich 1","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Southern Gulf Islands","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"T''Sou-ke","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Union Bay 4","Type":"Indian reserve","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Victoria","Type":"City","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"View Royal","Type":"Town","RegionalDistrictCode":"1"},{"CensusSubdivisionName":"Agats Meadow 8","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Alexandria","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Alexis Creek 14","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Alexis Creek 16","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Alexis Creek 21","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Alexis Creek 34","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Alkali Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Alkali Lake 4A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Anahim''s Flat 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Anahim''s Meadow","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Baezaeko River 27","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Baptiste Meadow 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Betty Creek 18","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Canim Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Canim Lake 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Canim Lake 4","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Canoe Creek 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo A","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo B","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo C","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo D","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo E","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo F","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo G","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo H","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo I","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo J","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo K","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Cariboo L","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Charley Boy''s Meadow 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Chilco Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Chilco Lake 1A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Coglistiko River 29","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Deep Creek 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Dog Creek 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Dog Creek 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Dragon Lake 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Euchinico Creek 17","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Fishtrap 19","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Garden","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Johny Sticks 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Kluskus 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Lezbye 6","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Little Springs","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Lohbiee 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Michel Gardens 36","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Nazco 20","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"One Hundred Mile House","Type":"District municipality","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Puntzi Lake 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Quesnel","Type":"City","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Quesnel 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Redstone Flat 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Salmon River Meadow 7","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Sandy Harry 4","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Soda Creek 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Squinas 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Stone 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Sundayman''s Meadow 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Swan Lake 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Tanakut 4","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Thomas Squinas Ranch 2A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Toosey 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Towdystan Lake 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Trout Lake Alec 16","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Ulkatcho 13","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Ulkatcho 14A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Wells","Type":"District municipality","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Williams Lake","Type":"City","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Williams Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Windy Mouth 7","Type":"Indian reserve","RegionalDistrictCode":"22"},{"CensusSubdivisionName":"Bella Bella 1","Type":"Indian reserve","RegionalDistrictCode":"9"},{"CensusSubdivisionName":"Bella Coola 1","Type":"Indian reserve","RegionalDistrictCode":"9"},{"CensusSubdivisionName":"Central Coast A","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"CensusSubdivisionName":"Central Coast C","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"CensusSubdivisionName":"Central Coast D","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"CensusSubdivisionName":"Central Coast E","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"CensusSubdivisionName":"Katit 1","Type":"Indian reserve","RegionalDistrictCode":"9"},{"CensusSubdivisionName":"Castlegar","Type":"City","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay A","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay B","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay C","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay D","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay E","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay F","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay G","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay H","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay I","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay J","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Kootenay K","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Creston","Type":"Town","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Creston 1","Type":"Indian reserve","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Kaslo","Type":"Village","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Nakusp","Type":"Village","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Nelson","Type":"City","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"New Denver","Type":"Village","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Salmo","Type":"Village","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Silverton","Type":"Village","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Slocan","Type":"Village","RegionalDistrictCode":"20"},{"CensusSubdivisionName":"Central Okanagan","Type":"Regional district electoral area","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Central Okanagan West","Type":"Regional district electoral area","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Duck Lake 7","Type":"Indian reserve","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Kelowna","Type":"City","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Lake Country","Type":"District municipality","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Peachland","Type":"District municipality","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Tsinstikeptum 10","Type":"Indian reserve","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Tsinstikeptum 9","Type":"Indian reserve","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"West Kelowna","Type":"City","RegionalDistrictCode":"16"},{"CensusSubdivisionName":"Chum Creek 2","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Columbia-Shuswap A","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Columbia-Shuswap B","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Columbia-Shuswap C","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Columbia-Shuswap D","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Columbia-Shuswap E","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Columbia-Shuswap F","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Golden","Type":"Town","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Hustalen 1","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"North Bay 5","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Okanagan (Part) 1 - Thompson/Okanagan","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Quaaout 1","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Revelstoke","Type":"City","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Salmon Arm","Type":"City","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Salmon River 1","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Scotch Creek 4","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Sicamous","Type":"District municipality","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Switsemalph","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Switsemalph 3","Type":"Indian reserve","RegionalDistrictCode":"18"},{"CensusSubdivisionName":"Comox","Type":"Town","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Comox 1","Type":"Indian reserve","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Comox Valley A","Type":"Regional district electoral area","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Comox Valley B (Lazo North)","Type":"Regional district electoral area","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Comox Valley C (Puntledge - Black Creek)","Type":"Regional district electoral area","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Courtenay","Type":"City","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Cumberland","Type":"Village","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Pentledge 2","Type":"Indian reserve","RegionalDistrictCode":"6"},{"CensusSubdivisionName":"Chemainus 13","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Lake","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley A","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley B","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley C","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley D","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley E","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley F","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley G","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley H","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Cowichan Valley I","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Duncan","Type":"City","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Est-Patrolas 4","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Halalt 2","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Kil-pah-las 3","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Ladysmith","Type":"Town","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Lake Cowichan","Type":"Town","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Lyacksun 3","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Malachan 11","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Malahat 11","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"North Cowichan","Type":"District municipality","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Oyster Bay 12","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Penelakut Island 7","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Portier Pass 5","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Shingle Point 4","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Squaw-Hay-One 11","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Theik 2","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Tsussie 6","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Tzart-Lam 5","Type":"Indian reserve","RegionalDistrictCode":"2"},{"CensusSubdivisionName":"Canal Flats","Type":"Village","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Cassimayooks (Mayook) 5","Type":"Indian reserve","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Columbia Lake 3","Type":"Indian reserve","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Cranbrook","Type":"City","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"East Kootenay A","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"East Kootenay B","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"East Kootenay C","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"East Kootenay E","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"East Kootenay F","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"East Kootenay G","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Elkford","Type":"District municipality","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Fernie","Type":"City","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Invermere","Type":"District municipality","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Isidore''s Ranch 4","Type":"Indian reserve","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Kimberley","Type":"City","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Kootenay 1","Type":"Indian reserve","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Radium Hot Springs","Type":"Village","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Shuswap","Type":"Indian reserve","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Sparwood","Type":"District municipality","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"St. Mary''s","Type":"Indian reserve","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Tobacco Plains 2","Type":"Indian reserve","RegionalDistrictCode":"19"},{"CensusSubdivisionName":"Abbotsford","Type":"City","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Aitchelitch 9","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Albert Flat 5","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Boothroyd 13","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Boston Bar 1A","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Bucktum 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Chawathil 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Cheam 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Chehalis 5","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Chilliwack","Type":"City","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Douglas 8","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley A","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley B","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley C","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley D","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley E","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley F","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley G","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fraser Valley H","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Harrison Hot Springs","Type":"Village","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Holachten 8","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Hope","Type":"District municipality","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Inkahtsaph 6","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Kahmoose 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Kent","Type":"District municipality","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Kopchitchin 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Kwawkwawapilt 6","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Lakahahmen 11","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Langley 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Lukseetsissum 9","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Matsqui Main 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Mission","Type":"District municipality","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Ohamil 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Paqulh","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Peters 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Popkum 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Puckatholetchin 11","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Q''alatkú7em","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Ruby Creek 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Sachteen","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Saddle Rock 9","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Schelowat 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Schkam 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Scowlitz 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Seabird Island","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Skawahlook 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Skookumchuck 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Skowkale","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Skwah 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Skwali 3","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Skway 5","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Skweahm 10","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Soowahlie 14","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Speyum 3","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Spuzzum 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Squawkum Creek 3","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Squiaala","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Stullawheets 8","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Tipella 7","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Tseatah 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Tuckkwiowhum 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Tzeachten 13","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Upper Sumas 6","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Yakweakwioose 12","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Yale Town 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"CensusSubdivisionName":"Fort George 2","Type":"Indian reserve","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Fraser-Fort George A","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Fraser-Fort George C","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Fraser-Fort George D","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Fraser-Fort George E","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Fraser-Fort George F","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Fraser-Fort George G","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Fraser-Fort George H","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Mackenzie","Type":"District municipality","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"McBride","Type":"Village","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"McLeod Lake 1","Type":"Indian reserve","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Prince George","Type":"City","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Valemount","Type":"Village","RegionalDistrictCode":"23"},{"CensusSubdivisionName":"Anmore","Type":"Village","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Barnston Island 3","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Belcarra","Type":"Village","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Bowen Island","Type":"Island municipality","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Burnaby","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Burrard Inlet 3","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Capilano 5","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Coquitlam","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Coquitlam 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Coquitlam 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Delta","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Katzie 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Katzie 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Langley - City","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Langley - District municipality","Type":"District municipality","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Lions Bay","Type":"Village","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Maple Ridge","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Matsqui 4","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"McMillan Island 6","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Metro Vancouver A","Type":"Regional district electoral area","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Mission 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Musqueam 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Musqueam 4","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"New Westminster","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"North Vancouver - City","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"North Vancouver - District municipality","Type":"District municipality","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Pitt Meadows","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Port Coquitlam","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Port Moody","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Richmond","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Semiahmoo","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Seymour Creek 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Surrey","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Tsawwassen","Type":"Tsawwassen Lands","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Vancouver","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"West Vancouver","Type":"District municipality","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"White Rock","Type":"City","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Whonnock 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"CensusSubdivisionName":"Babine 17","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Bulkley River 19","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Coryatsaqua (Moricetown) 2","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Dease Lake 9","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Gitanmaax 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Gitanyow 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Gitsegukla 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Gitwangak 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Guhthe Tah 12","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Hagwilget 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Hazelton","Type":"Village","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Iskut 6","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kispiox 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitamaat 2","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitasoo 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat","Type":"District municipality","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat-Stikine A","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat-Stikine B","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat-Stikine C (Part 1)","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat-Stikine C (Part 2)","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat-Stikine D","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat-Stikine E","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitimat-Stikine F","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitselas 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kitsumkaylum 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kshish 4","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Kulspai 6","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Moricetown 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"New Hazelton","Type":"District municipality","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Nisga''a","Type":"Nisga''a land","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Sik-e-dakh 2","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Stewart","Type":"District municipality","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Tahltan 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Telegraph Creek","Type":"Indian reserve","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Terrace","Type":"City","RegionalDistrictCode":"25"},{"CensusSubdivisionName":"Fruitvale","Type":"Village","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Grand Forks","Type":"City","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Greenwood","Type":"City","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Kootenay Boundary A","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Kootenay Boundary B / Lower Columbia-Old-Glory","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Kootenay Boundary C / Christina Lake","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Kootenay Boundary D / Rural Grand Forks","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Kootenay Boundary E / West Boundary","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Midway","Type":"Village","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Montrose","Type":"Village","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Rossland","Type":"City","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Trail","Type":"City","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Warfield","Type":"Village","RegionalDistrictCode":"21"},{"CensusSubdivisionName":"Alert Bay - Indian reserve","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Alert Bay - Village","Type":"Village","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Dead Point 5","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Fort Rupert 1","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Gwayasdums 1","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Hope Island 1","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Hopetown 10A","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Kippase 2","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Mount Waddington A","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Mount Waddington B","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Mount Waddington C","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Mount Waddington D","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Nimpkish 2","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Port Alice","Type":"Village","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Port Hardy","Type":"District municipality","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Port McNeill","Type":"Town","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Quaee 7","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Quatsino Subdivision 18","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Thomas Point 5","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Thomas Point 5A","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Tsulquate 4","Type":"Indian reserve","RegionalDistrictCode":"8"},{"CensusSubdivisionName":"Lantzville","Type":"District municipality","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo","Type":"City","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo A","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo B","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo C","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo E","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo F","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo G","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo H","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo River","Type":"Indian reserve","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanaimo Town 1","Type":"Indian reserve","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Nanoose","Type":"Indian reserve","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Parksville","Type":"City","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Qualicum","Type":"Indian reserve","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Qualicum Beach","Type":"Town","RegionalDistrictCode":"3"},{"CensusSubdivisionName":"Armstrong","Type":"City","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Coldstream","Type":"District municipality","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Enderby","Type":"City","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Enderby 2","Type":"Indian reserve","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Harris 3","Type":"Indian reserve","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Lumby","Type":"Village","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"North Okanagan B","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"North Okanagan C","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"North Okanagan D","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"North Okanagan E","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"North Okanagan F","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Okanagan (Part) 1 - North Okanagan","Type":"Indian reserve","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Priest''s Valley 6","Type":"Indian reserve","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Spallumcheen","Type":"District municipality","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Vernon","Type":"City","RegionalDistrictCode":"17"},{"CensusSubdivisionName":"Fontas 1","Type":"Indian reserve","RegionalDistrictCode":"29"},{"CensusSubdivisionName":"Fort Nelson 2","Type":"Indian reserve","RegionalDistrictCode":"29"},{"CensusSubdivisionName":"Kahntah 3","Type":"Indian reserve","RegionalDistrictCode":"29"},{"CensusSubdivisionName":"Northern Rockies","Type":"Regional municipality","RegionalDistrictCode":"29"},{"CensusSubdivisionName":"Prophet River 4","Type":"Indian reserve","RegionalDistrictCode":"29"},{"CensusSubdivisionName":"Alexis 9","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Ashnola 10","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Blind Creek 6","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Chopaka 7 & 8","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Chuchuwayha 2","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Keremeos","Type":"Village","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Lower Similkameen 2","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Lulu 5","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen A","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen B","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen C","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen D","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen E","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen F","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen G","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen H","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Okanagan-Similkameen I","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Oliver","Type":"Town","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Osoyoos","Type":"Town","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Osoyoos 1","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Penticton","Type":"City","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Penticton 1","Type":"Indian reserve","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Princeton","Type":"Town","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Summerland","Type":"District municipality","RegionalDistrictCode":"14"},{"CensusSubdivisionName":"Blueberry River 205","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Chetwynd","Type":"District municipality","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Dawson Creek","Type":"City","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Doig River 206","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"East Moberly Lake 169","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Finlay River 6","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Fort St. John","Type":"City","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Fort Ware 1","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Halfway River 168","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Hudson''s Hope","Type":"District municipality","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Ingenika Point","Type":"Indian settlement","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Mesilinka 7","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Peace River B","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Peace River C","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Peace River D","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Peace River E","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Pouce Coupe","Type":"Village","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Taylor","Type":"District municipality","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Tumbler Ridge","Type":"District municipality","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"West Moberly Lake 168A","Type":"Indian reserve","RegionalDistrictCode":"28"},{"CensusSubdivisionName":"Powell River","Type":"City","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"qathet A","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"qathet B","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"qathet C","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"qathet D","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"qathet E","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"Sechelt (Part) - qathet","Type":"Indian government district","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"Sliammon 1","Type":"Tla''amin Lands","RegionalDistrictCode":"7"},{"CensusSubdivisionName":"Dolphin Island 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Kulkayu (Hartley Bay) 4","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Kulkayu (Hartley Bay) 4A","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Lax Kw''alaams 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Masset","Type":"Village","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Masset 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"North Coast A","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"North Coast C","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"North Coast D","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"North Coast E","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Port Clements","Type":"Village","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Port Edward","Type":"District municipality","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Prince Rupert","Type":"City","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Queen Charlotte","Type":"Village","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"S1/2 Tsimpsean 2","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Skidegate 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Tlaa Gaa Aawtlaas 28","Type":"Indian reserve","RegionalDistrictCode":"24"},{"CensusSubdivisionName":"Bridge River 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Cayoosh Creek 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Cheakamus 11","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Chilhil 6","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 10","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 11","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 12","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 1B","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 1D","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 3","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain 3A","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Fountain Creek 8","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Kowtain 17","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Lillooet","Type":"District municipality","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Lillooet 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"McCartney''s Flat 4","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Mission 5","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Mount Currie","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Necait 6","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Nequatque","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Nesikep 6","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Nesuch 3","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Pashilqua 2","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Pavilion 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Pemberton","Type":"Village","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Seaichem 16","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Seton Lake 5","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Slosh 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Squamish","Type":"District municipality","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Squamish-Lillooet A","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Squamish-Lillooet B","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Squamish-Lillooet C","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Squamish-Lillooet D","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Stawamus 24","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Towinock 2","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Waiwakum 14","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Whistler","Type":"District municipality","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Yekwaupsum 18","Type":"Indian reserve","RegionalDistrictCode":"13"},{"CensusSubdivisionName":"Dease River 1","Type":"Indian reserve","RegionalDistrictCode":"27"},{"CensusSubdivisionName":"Five Mile Point 3","Type":"Indian reserve","RegionalDistrictCode":"27"},{"CensusSubdivisionName":"Good Hope Lake","Type":"Indian settlement","RegionalDistrictCode":"27"},{"CensusSubdivisionName":"Liard River 3","Type":"Indian reserve","RegionalDistrictCode":"27"},{"CensusSubdivisionName":"Lower Post","Type":"Indian settlement","RegionalDistrictCode":"27"},{"CensusSubdivisionName":"Stikine Region","Type":"Regional district electoral area","RegionalDistrictCode":"27"},{"CensusSubdivisionName":"Unnamed 10","Type":"Indian reserve","RegionalDistrictCode":"27"},{"CensusSubdivisionName":"Ahaminaquus 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Campbell River","Type":"City","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Campbell River 11","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Cape Mudge 10","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Chenahkint 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Ehatis 11","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Gold River","Type":"Village","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Homalco 9","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Houpsitas 6","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Nenagwas 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Oclucje 7","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Quinsam 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Sayward","Type":"Village","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Squirrel Cove 8","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Strathcona A","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Strathcona B","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Strathcona C","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Strathcona D (Oyster Bay - Buttle Lake)","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Tahsis","Type":"Village","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Tork 7","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Tsa Xana 18","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Village Island 1","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Yuquot 1","Type":"Indian reserve","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Zeballos","Type":"Village","RegionalDistrictCode":"5"},{"CensusSubdivisionName":"Chekwelp 26","Type":"Indian reserve","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Gibsons","Type":"Town","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Sechelt","Type":"District municipality","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Sechelt (Part) - Sunshine Coast","Type":"Indian government district","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Sunshine Coast A","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Sunshine Coast B","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Sunshine Coast D","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Sunshine Coast E","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"Sunshine Coast F","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"CensusSubdivisionName":"105 Mile Post 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Ashcroft","Type":"Village","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Ashcroft 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Barriere","Type":"District municipality","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Basque 18","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Bonaparte 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Boothroyd 8A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Cache Creek","Type":"Village","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Canoe Creek 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Canoe Creek 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Chase","Type":"Village","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Chuchhraischin","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Clearwater","Type":"District municipality","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Clinton","Type":"Village","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Coldwater 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Douglas Lake 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Entlqwekkinh 19","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Halhalaeden","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Hamilton Creek 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"High Bar 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Inkluckcheen","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Inklyuhkinatko 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Joeyaska 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Kamloops","Type":"City","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Kamloops 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Kanaka Bar","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Kitzowit 20","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Klahkamich 17","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Kleetlekut 22","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Klickkumcheen 18","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Kloklowuck 7","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Kumcheen 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Logan Lake","Type":"District municipality","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Louis Creek 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Lower Hat Creek 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Lytton","Type":"Village","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Lytton 4A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Lytton 4E","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Lytton 9A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Lytton 9B","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Marble Canyon 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Merritt","Type":"City","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nekalliston 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nekliptum 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Neskonlith","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nickel Palm 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nickeyeah 25","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nicola Lake 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nicola Mameet 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nicomen 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nkaih 10","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nohomeen 23","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nooaitch 10","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"North Thompson 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Nuuautin 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Oregon Jack Creek 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Papyum 27","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Paska Island 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Paul''s Basin 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Pemynoos 9","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Peq-Paq 22","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Sahhaltkum 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Seah 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Shackan 11","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Shawniken 4B","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Siska Flat","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Skeetchestn","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Skuppah 2A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Skuppah 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Skwayaynope 26","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Spences Bridge","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Spintlum Flat 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Squaam 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Stequmwhulpa 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Stryen 9","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Sun Peaks Mountain","Type":"Village","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola A (Wells Gray Country)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola B (Thompson Headwaters)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola E (Bonaparte Plateau)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola I (Blue Sky Country)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola J (Copper Desert Country)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola L (Grasslands)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola M (Beautiful Nicola Valley - North)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola N (Beautiful Nicola Valley - South)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola O (Lower North Thompson)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Thompson-Nicola P (Rivers and the Peaks)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Tsaukan 12","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Twoyqhalsht 16","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Upper Hat Creek 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Upper Nepa 6","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Whispering Pines 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Yawaucht 11","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Zacht 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"CensusSubdivisionName":"Zoht 4","Type":"Indian reserve","RegionalDistrictCode":"15"}]'; -BEGIN - -- Insert into "CensusSubdivisions" table - INSERT INTO public."CensusSubdivisions" - ("Id", "CensusSubdivisionName", "Type","RegionalDistrictCode", "ExtraProperties", "ConcurrencyStamp", "CreationTime") - SELECT - gen_random_uuid(), - data->>'CensusSubdivisionName', - data->>'Type', - data->>'RegionalDistrictCode', - '', - '', - pg_catalog.now() - FROM jsonb_array_elements(json_data::jsonb) AS data; - -END $$; diff --git a/database/scripts/unitydb-communities-script.sql b/database/scripts/unitydb-communities-script.sql index 617be7f97..0f0d27b3a 100644 --- a/database/scripts/unitydb-communities-script.sql +++ b/database/scripts/unitydb-communities-script.sql @@ -1,7 +1,10 @@ -DO $$ +DO $$ DECLARE - json_data jsonb := '[{"Name":"Ahahswinis 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Alberni 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Alberni-Clayoquot A","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"Name":"Alberni-Clayoquot B","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"Name":"Alberni-Clayoquot C","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"Name":"Alberni-Clayoquot D","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"Name":"Alberni-Clayoquot E","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"Name":"Alberni-Clayoquot F","Type":"Regional district electoral area","RegionalDistrictCode":"4"},{"Name":"Anacla 12","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Clakamucus 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Elhlateese 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Esowista 3","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Hesquiat 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Ittatsoo 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Klehkoot 2","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Macoah 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Marktosis 15","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Numukamis 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Opitsat 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Port Alberni","Type":"City","RegionalDistrictCode":"4"},{"Name":"Refuge Cove 6","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Sachsa 4","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Tin Wis 11","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Tofino","Type":"District municipality","RegionalDistrictCode":"4"},{"Name":"Tsahaheh 1","Type":"Indian reserve","RegionalDistrictCode":"4"},{"Name":"Ucluelet","Type":"District municipality","RegionalDistrictCode":"4"},{"Name":"Babine 16","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Babine 25","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Babine 6","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Babine Lake 21B","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Binche 2","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Bulkley-Nechako A","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"Name":"Bulkley-Nechako B","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"Name":"Bulkley-Nechako C","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"Name":"Bulkley-Nechako D","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"Name":"Bulkley-Nechako E","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"Name":"Bulkley-Nechako F","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"Name":"Bulkley-Nechako G","Type":"Regional district electoral area","RegionalDistrictCode":"26"},{"Name":"Burns Lake","Type":"Village","RegionalDistrictCode":"26"},{"Name":"Burns Lake 18","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Cheslatta 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Duncan Lake 2","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Dzitline Lee 9","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Fort St. James","Type":"District municipality","RegionalDistrictCode":"26"},{"Name":"Francois Lake 7","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Fraser Lake","Type":"Village","RegionalDistrictCode":"26"},{"Name":"Granisle","Type":"Village","RegionalDistrictCode":"26"},{"Name":"Houston","Type":"District municipality","RegionalDistrictCode":"26"},{"Name":"Jean Baptiste 28","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Laketown 3","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Mission Lands 17","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Nak''azdli","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Nautley (Fort Fraser) 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Nedoats 11","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Noonla 6","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"North Tacla Lake","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Omineca 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Palling 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Poison Creek 17A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Seaspunkut 4","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Skins Lake 16A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Skins Lake 16B","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Smithers","Type":"Town","RegionalDistrictCode":"26"},{"Name":"Sowchea 3","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Stellaquo (Stella) 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Stony Creek 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Tache 1","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Tacla Lake (Ferry Landing) 9","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Tadinlay 15","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Tatla West 11","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Tatla''t East 2","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Telkwa","Type":"Village","RegionalDistrictCode":"26"},{"Name":"Uncha Lake 13A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Vanderhoof","Type":"District municipality","RegionalDistrictCode":"26"},{"Name":"Williams Prairie Meadow 1A","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Woyenne 27","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Ye Koo Che 3","Type":"Indian reserve","RegionalDistrictCode":"26"},{"Name":"Becher Bay 1","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Central Saanich","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"Cole Bay 3","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Colwood","Type":"City","RegionalDistrictCode":"1"},{"Name":"East Saanich 2","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Esquimalt - District municipality","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"Esquimalt - Indian reserve","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Galiano Island 9","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Gordon River 2","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Highlands","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"Juan de Fuca (Part 1)","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"Name":"Juan de Fuca (Part 2)","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"Name":"Langford","Type":"City","RegionalDistrictCode":"1"},{"Name":"Metchosin","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"New Songhees 1A","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"North Saanich","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"Oak Bay","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"Saanich","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"Saltspring Island","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"Name":"Sidney","Type":"Town","RegionalDistrictCode":"1"},{"Name":"Sooke","Type":"District municipality","RegionalDistrictCode":"1"},{"Name":"South Saanich 1","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Southern Gulf Islands","Type":"Regional district electoral area","RegionalDistrictCode":"1"},{"Name":"T''Sou-ke","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Union Bay 4","Type":"Indian reserve","RegionalDistrictCode":"1"},{"Name":"Victoria","Type":"City","RegionalDistrictCode":"1"},{"Name":"View Royal","Type":"Town","RegionalDistrictCode":"1"},{"Name":"Agats Meadow 8","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Alexandria","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Alexis Creek 14","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Alexis Creek 16","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Alexis Creek 21","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Alexis Creek 34","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Alkali Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Alkali Lake 4A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Anahim''s Flat 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Anahim''s Meadow","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Baezaeko River 27","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Baptiste Meadow 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Betty Creek 18","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Canim Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Canim Lake 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Canim Lake 4","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Canoe Creek 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Cariboo A","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo B","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo C","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo D","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo E","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo F","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo G","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo H","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo I","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo J","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo K","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Cariboo L","Type":"Regional district electoral area","RegionalDistrictCode":"22"},{"Name":"Charley Boy''s Meadow 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Chilco Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Chilco Lake 1A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Coglistiko River 29","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Deep Creek 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Dog Creek 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Dog Creek 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Dragon Lake 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Euchinico Creek 17","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Fishtrap 19","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Garden","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Johny Sticks 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Kluskus 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Lezbye 6","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Little Springs","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Lohbiee 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Michel Gardens 36","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Nazco 20","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"One Hundred Mile House","Type":"District municipality","RegionalDistrictCode":"22"},{"Name":"Puntzi Lake 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Quesnel","Type":"City","RegionalDistrictCode":"22"},{"Name":"Quesnel 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Redstone Flat 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Salmon River Meadow 7","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Sandy Harry 4","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Soda Creek 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Squinas 2","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Stone 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Sundayman''s Meadow 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Swan Lake 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Tanakut 4","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Thomas Squinas Ranch 2A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Toosey 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Towdystan Lake 3","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Trout Lake Alec 16","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Ulkatcho 13","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Ulkatcho 14A","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Wells","Type":"District municipality","RegionalDistrictCode":"22"},{"Name":"Williams Lake","Type":"City","RegionalDistrictCode":"22"},{"Name":"Williams Lake 1","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Windy Mouth 7","Type":"Indian reserve","RegionalDistrictCode":"22"},{"Name":"Bella Bella 1","Type":"Indian reserve","RegionalDistrictCode":"9"},{"Name":"Bella Coola 1","Type":"Indian reserve","RegionalDistrictCode":"9"},{"Name":"Central Coast A","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"Name":"Central Coast C","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"Name":"Central Coast D","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"Name":"Central Coast E","Type":"Regional district electoral area","RegionalDistrictCode":"9"},{"Name":"Katit 1","Type":"Indian reserve","RegionalDistrictCode":"9"},{"Name":"Castlegar","Type":"City","RegionalDistrictCode":"20"},{"Name":"Central Kootenay A","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay B","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay C","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay D","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay E","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay F","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay G","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay H","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay I","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay J","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Central Kootenay K","Type":"Regional district electoral area","RegionalDistrictCode":"20"},{"Name":"Creston","Type":"Town","RegionalDistrictCode":"20"},{"Name":"Creston 1","Type":"Indian reserve","RegionalDistrictCode":"20"},{"Name":"Kaslo","Type":"Village","RegionalDistrictCode":"20"},{"Name":"Nakusp","Type":"Village","RegionalDistrictCode":"20"},{"Name":"Nelson","Type":"City","RegionalDistrictCode":"20"},{"Name":"New Denver","Type":"Village","RegionalDistrictCode":"20"},{"Name":"Salmo","Type":"Village","RegionalDistrictCode":"20"},{"Name":"Silverton","Type":"Village","RegionalDistrictCode":"20"},{"Name":"Slocan","Type":"Village","RegionalDistrictCode":"20"},{"Name":"Central Okanagan","Type":"Regional district electoral area","RegionalDistrictCode":"16"},{"Name":"Central Okanagan West","Type":"Regional district electoral area","RegionalDistrictCode":"16"},{"Name":"Duck Lake 7","Type":"Indian reserve","RegionalDistrictCode":"16"},{"Name":"Kelowna","Type":"City","RegionalDistrictCode":"16"},{"Name":"Lake Country","Type":"District municipality","RegionalDistrictCode":"16"},{"Name":"Peachland","Type":"District municipality","RegionalDistrictCode":"16"},{"Name":"Tsinstikeptum 10","Type":"Indian reserve","RegionalDistrictCode":"16"},{"Name":"Tsinstikeptum 9","Type":"Indian reserve","RegionalDistrictCode":"16"},{"Name":"West Kelowna","Type":"City","RegionalDistrictCode":"16"},{"Name":"Chum Creek 2","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Columbia-Shuswap A","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"Name":"Columbia-Shuswap B","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"Name":"Columbia-Shuswap C","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"Name":"Columbia-Shuswap D","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"Name":"Columbia-Shuswap E","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"Name":"Columbia-Shuswap F","Type":"Regional district electoral area","RegionalDistrictCode":"18"},{"Name":"Golden","Type":"Town","RegionalDistrictCode":"18"},{"Name":"Hustalen 1","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"North Bay 5","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Okanagan (Part) 1 - Thompson/Okanagan","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Quaaout 1","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Revelstoke","Type":"City","RegionalDistrictCode":"18"},{"Name":"Salmon Arm","Type":"City","RegionalDistrictCode":"18"},{"Name":"Salmon River 1","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Scotch Creek 4","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Sicamous","Type":"District municipality","RegionalDistrictCode":"18"},{"Name":"Switsemalph","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Switsemalph 3","Type":"Indian reserve","RegionalDistrictCode":"18"},{"Name":"Comox","Type":"Town","RegionalDistrictCode":"6"},{"Name":"Comox 1","Type":"Indian reserve","RegionalDistrictCode":"6"},{"Name":"Comox Valley A","Type":"Regional district electoral area","RegionalDistrictCode":"6"},{"Name":"Comox Valley B (Lazo North)","Type":"Regional district electoral area","RegionalDistrictCode":"6"},{"Name":"Comox Valley C (Puntledge - Black Creek)","Type":"Regional district electoral area","RegionalDistrictCode":"6"},{"Name":"Courtenay","Type":"City","RegionalDistrictCode":"6"},{"Name":"Cumberland","Type":"Village","RegionalDistrictCode":"6"},{"Name":"Pentledge 2","Type":"Indian reserve","RegionalDistrictCode":"6"},{"Name":"Chemainus 13","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Cowichan","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Cowichan Lake","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley A","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley B","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley C","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley D","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley E","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley F","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley G","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley H","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Cowichan Valley I","Type":"Regional district electoral area","RegionalDistrictCode":"2"},{"Name":"Duncan","Type":"City","RegionalDistrictCode":"2"},{"Name":"Est-Patrolas 4","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Halalt 2","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Kil-pah-las 3","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Ladysmith","Type":"Town","RegionalDistrictCode":"2"},{"Name":"Lake Cowichan","Type":"Town","RegionalDistrictCode":"2"},{"Name":"Lyacksun 3","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Malachan 11","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Malahat 11","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"North Cowichan","Type":"District municipality","RegionalDistrictCode":"2"},{"Name":"Oyster Bay 12","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Penelakut Island 7","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Portier Pass 5","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Shingle Point 4","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Squaw-Hay-One 11","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Theik 2","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Tsussie 6","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Tzart-Lam 5","Type":"Indian reserve","RegionalDistrictCode":"2"},{"Name":"Canal Flats","Type":"Village","RegionalDistrictCode":"19"},{"Name":"Cassimayooks (Mayook) 5","Type":"Indian reserve","RegionalDistrictCode":"19"},{"Name":"Columbia Lake 3","Type":"Indian reserve","RegionalDistrictCode":"19"},{"Name":"Cranbrook","Type":"City","RegionalDistrictCode":"19"},{"Name":"East Kootenay A","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"Name":"East Kootenay B","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"Name":"East Kootenay C","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"Name":"East Kootenay E","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"Name":"East Kootenay F","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"Name":"East Kootenay G","Type":"Regional district electoral area","RegionalDistrictCode":"19"},{"Name":"Elkford","Type":"District municipality","RegionalDistrictCode":"19"},{"Name":"Fernie","Type":"City","RegionalDistrictCode":"19"},{"Name":"Invermere","Type":"District municipality","RegionalDistrictCode":"19"},{"Name":"Isidore''s Ranch 4","Type":"Indian reserve","RegionalDistrictCode":"19"},{"Name":"Kimberley","Type":"City","RegionalDistrictCode":"19"},{"Name":"Kootenay 1","Type":"Indian reserve","RegionalDistrictCode":"19"},{"Name":"Radium Hot Springs","Type":"Village","RegionalDistrictCode":"19"},{"Name":"Shuswap","Type":"Indian reserve","RegionalDistrictCode":"19"},{"Name":"Sparwood","Type":"District municipality","RegionalDistrictCode":"19"},{"Name":"St. Mary''s","Type":"Indian reserve","RegionalDistrictCode":"19"},{"Name":"Tobacco Plains 2","Type":"Indian reserve","RegionalDistrictCode":"19"},{"Name":"Abbotsford","Type":"City","RegionalDistrictCode":"10"},{"Name":"Aitchelitch 9","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Albert Flat 5","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Boothroyd 13","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Boston Bar 1A","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Bucktum 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Chawathil 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Cheam 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Chehalis 5","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Chilliwack","Type":"City","RegionalDistrictCode":"10"},{"Name":"Douglas 8","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Fraser Valley A","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Fraser Valley B","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Fraser Valley C","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Fraser Valley D","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Fraser Valley E","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Fraser Valley F","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Fraser Valley G","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Fraser Valley H","Type":"Regional district electoral area","RegionalDistrictCode":"10"},{"Name":"Harrison Hot Springs","Type":"Village","RegionalDistrictCode":"10"},{"Name":"Holachten 8","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Hope","Type":"District municipality","RegionalDistrictCode":"10"},{"Name":"Inkahtsaph 6","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Kahmoose 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Kent","Type":"District municipality","RegionalDistrictCode":"10"},{"Name":"Kopchitchin 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Kwawkwawapilt 6","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Lakahahmen 11","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Langley 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Lukseetsissum 9","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Matsqui Main 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Mission","Type":"District municipality","RegionalDistrictCode":"10"},{"Name":"Ohamil 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Paqulh","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Peters 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Popkum 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Puckatholetchin 11","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Q''alatkú7em","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Ruby Creek 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Sachteen","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Saddle Rock 9","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Schelowat 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Schkam 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Scowlitz 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Seabird Island","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Skawahlook 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Skookumchuck 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Skowkale","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Skwah 4","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Skwali 3","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Skway 5","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Skweahm 10","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Soowahlie 14","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Speyum 3","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Spuzzum 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Squawkum Creek 3","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Squiaala","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Stullawheets 8","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Tipella 7","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Tseatah 2","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Tuckkwiowhum 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Tzeachten 13","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Upper Sumas 6","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Yakweakwioose 12","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Yale Town 1","Type":"Indian reserve","RegionalDistrictCode":"10"},{"Name":"Fort George 2","Type":"Indian reserve","RegionalDistrictCode":"23"},{"Name":"Fraser-Fort George A","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"Name":"Fraser-Fort George C","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"Name":"Fraser-Fort George D","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"Name":"Fraser-Fort George E","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"Name":"Fraser-Fort George F","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"Name":"Fraser-Fort George G","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"Name":"Fraser-Fort George H","Type":"Regional district electoral area","RegionalDistrictCode":"23"},{"Name":"Mackenzie","Type":"District municipality","RegionalDistrictCode":"23"},{"Name":"McBride","Type":"Village","RegionalDistrictCode":"23"},{"Name":"McLeod Lake 1","Type":"Indian reserve","RegionalDistrictCode":"23"},{"Name":"Prince George","Type":"City","RegionalDistrictCode":"23"},{"Name":"Valemount","Type":"Village","RegionalDistrictCode":"23"},{"Name":"Anmore","Type":"Village","RegionalDistrictCode":"11"},{"Name":"Barnston Island 3","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Belcarra","Type":"Village","RegionalDistrictCode":"11"},{"Name":"Bowen Island","Type":"Island municipality","RegionalDistrictCode":"11"},{"Name":"Burnaby","Type":"City","RegionalDistrictCode":"11"},{"Name":"Burrard Inlet 3","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Capilano 5","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Coquitlam","Type":"City","RegionalDistrictCode":"11"},{"Name":"Coquitlam 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Coquitlam 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Delta","Type":"City","RegionalDistrictCode":"11"},{"Name":"Katzie 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Katzie 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Langley - City","Type":"City","RegionalDistrictCode":"11"},{"Name":"Langley - District municipality","Type":"District municipality","RegionalDistrictCode":"11"},{"Name":"Lions Bay","Type":"Village","RegionalDistrictCode":"11"},{"Name":"Maple Ridge","Type":"City","RegionalDistrictCode":"11"},{"Name":"Matsqui 4","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"McMillan Island 6","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Metro Vancouver A","Type":"Regional district electoral area","RegionalDistrictCode":"11"},{"Name":"Mission 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Musqueam 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Musqueam 4","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"New Westminster","Type":"City","RegionalDistrictCode":"11"},{"Name":"North Vancouver - City","Type":"City","RegionalDistrictCode":"11"},{"Name":"North Vancouver - District municipality","Type":"District municipality","RegionalDistrictCode":"11"},{"Name":"Pitt Meadows","Type":"City","RegionalDistrictCode":"11"},{"Name":"Port Coquitlam","Type":"City","RegionalDistrictCode":"11"},{"Name":"Port Moody","Type":"City","RegionalDistrictCode":"11"},{"Name":"Richmond","Type":"City","RegionalDistrictCode":"11"},{"Name":"Semiahmoo","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Seymour Creek 2","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Surrey","Type":"City","RegionalDistrictCode":"11"},{"Name":"Tsawwassen","Type":"Tsawwassen Lands","RegionalDistrictCode":"11"},{"Name":"Vancouver","Type":"City","RegionalDistrictCode":"11"},{"Name":"West Vancouver","Type":"District municipality","RegionalDistrictCode":"11"},{"Name":"White Rock","Type":"City","RegionalDistrictCode":"11"},{"Name":"Whonnock 1","Type":"Indian reserve","RegionalDistrictCode":"11"},{"Name":"Babine 17","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Bulkley River 19","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Coryatsaqua (Moricetown) 2","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Dease Lake 9","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Gitanmaax 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Gitanyow 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Gitsegukla 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Gitwangak 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Guhthe Tah 12","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Hagwilget 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Hazelton","Type":"Village","RegionalDistrictCode":"25"},{"Name":"Iskut 6","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Kispiox 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Kitamaat 2","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Kitasoo 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Kitimat","Type":"District municipality","RegionalDistrictCode":"25"},{"Name":"Kitimat-Stikine A","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"Name":"Kitimat-Stikine B","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"Name":"Kitimat-Stikine C (Part 1)","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"Name":"Kitimat-Stikine C (Part 2)","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"Name":"Kitimat-Stikine D","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"Name":"Kitimat-Stikine E","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"Name":"Kitimat-Stikine F","Type":"Regional district electoral area","RegionalDistrictCode":"25"},{"Name":"Kitselas 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Kitsumkaylum 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Kshish 4","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Kulspai 6","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Moricetown 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"New Hazelton","Type":"District municipality","RegionalDistrictCode":"25"},{"Name":"Nisga''a","Type":"Nisga''a land","RegionalDistrictCode":"25"},{"Name":"Sik-e-dakh 2","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Stewart","Type":"District municipality","RegionalDistrictCode":"25"},{"Name":"Tahltan 1","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Telegraph Creek","Type":"Indian reserve","RegionalDistrictCode":"25"},{"Name":"Terrace","Type":"City","RegionalDistrictCode":"25"},{"Name":"Fruitvale","Type":"Village","RegionalDistrictCode":"21"},{"Name":"Grand Forks","Type":"City","RegionalDistrictCode":"21"},{"Name":"Greenwood","Type":"City","RegionalDistrictCode":"21"},{"Name":"Kootenay Boundary A","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"Name":"Kootenay Boundary B / Lower Columbia-Old-Glory","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"Name":"Kootenay Boundary C / Christina Lake","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"Name":"Kootenay Boundary D / Rural Grand Forks","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"Name":"Kootenay Boundary E / West Boundary","Type":"Regional district electoral area","RegionalDistrictCode":"21"},{"Name":"Midway","Type":"Village","RegionalDistrictCode":"21"},{"Name":"Montrose","Type":"Village","RegionalDistrictCode":"21"},{"Name":"Rossland","Type":"City","RegionalDistrictCode":"21"},{"Name":"Trail","Type":"City","RegionalDistrictCode":"21"},{"Name":"Warfield","Type":"Village","RegionalDistrictCode":"21"},{"Name":"Alert Bay - Indian reserve","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Alert Bay - Village","Type":"Village","RegionalDistrictCode":"8"},{"Name":"Dead Point 5","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Fort Rupert 1","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Gwayasdums 1","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Hope Island 1","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Hopetown 10A","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Kippase 2","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Mount Waddington A","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"Name":"Mount Waddington B","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"Name":"Mount Waddington C","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"Name":"Mount Waddington D","Type":"Regional district electoral area","RegionalDistrictCode":"8"},{"Name":"Nimpkish 2","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Port Alice","Type":"Village","RegionalDistrictCode":"8"},{"Name":"Port Hardy","Type":"District municipality","RegionalDistrictCode":"8"},{"Name":"Port McNeill","Type":"Town","RegionalDistrictCode":"8"},{"Name":"Quaee 7","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Quatsino Subdivision 18","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Thomas Point 5","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Thomas Point 5A","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Tsulquate 4","Type":"Indian reserve","RegionalDistrictCode":"8"},{"Name":"Lantzville","Type":"District municipality","RegionalDistrictCode":"3"},{"Name":"Nanaimo","Type":"City","RegionalDistrictCode":"3"},{"Name":"Nanaimo A","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"Name":"Nanaimo B","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"Name":"Nanaimo C","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"Name":"Nanaimo E","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"Name":"Nanaimo F","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"Name":"Nanaimo G","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"Name":"Nanaimo H","Type":"Regional district electoral area","RegionalDistrictCode":"3"},{"Name":"Nanaimo River","Type":"Indian reserve","RegionalDistrictCode":"3"},{"Name":"Nanaimo Town 1","Type":"Indian reserve","RegionalDistrictCode":"3"},{"Name":"Nanoose","Type":"Indian reserve","RegionalDistrictCode":"3"},{"Name":"Parksville","Type":"City","RegionalDistrictCode":"3"},{"Name":"Qualicum","Type":"Indian reserve","RegionalDistrictCode":"3"},{"Name":"Qualicum Beach","Type":"Town","RegionalDistrictCode":"3"},{"Name":"Armstrong","Type":"City","RegionalDistrictCode":"17"},{"Name":"Coldstream","Type":"District municipality","RegionalDistrictCode":"17"},{"Name":"Enderby","Type":"City","RegionalDistrictCode":"17"},{"Name":"Enderby 2","Type":"Indian reserve","RegionalDistrictCode":"17"},{"Name":"Harris 3","Type":"Indian reserve","RegionalDistrictCode":"17"},{"Name":"Lumby","Type":"Village","RegionalDistrictCode":"17"},{"Name":"North Okanagan B","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"Name":"North Okanagan C","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"Name":"North Okanagan D","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"Name":"North Okanagan E","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"Name":"North Okanagan F","Type":"Regional district electoral area","RegionalDistrictCode":"17"},{"Name":"Okanagan (Part) 1 - North Okanagan","Type":"Indian reserve","RegionalDistrictCode":"17"},{"Name":"Priest''s Valley 6","Type":"Indian reserve","RegionalDistrictCode":"17"},{"Name":"Spallumcheen","Type":"District municipality","RegionalDistrictCode":"17"},{"Name":"Vernon","Type":"City","RegionalDistrictCode":"17"},{"Name":"Fontas 1","Type":"Indian reserve","RegionalDistrictCode":"29"},{"Name":"Fort Nelson 2","Type":"Indian reserve","RegionalDistrictCode":"29"},{"Name":"Kahntah 3","Type":"Indian reserve","RegionalDistrictCode":"29"},{"Name":"Northern Rockies","Type":"Regional municipality","RegionalDistrictCode":"29"},{"Name":"Prophet River 4","Type":"Indian reserve","RegionalDistrictCode":"29"},{"Name":"Alexis 9","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Ashnola 10","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Blind Creek 6","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Chopaka 7 & 8","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Chuchuwayha 2","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Keremeos","Type":"Village","RegionalDistrictCode":"14"},{"Name":"Lower Similkameen 2","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Lulu 5","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen A","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen B","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen C","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen D","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen E","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen F","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen G","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen H","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Okanagan-Similkameen I","Type":"Regional district electoral area","RegionalDistrictCode":"14"},{"Name":"Oliver","Type":"Town","RegionalDistrictCode":"14"},{"Name":"Osoyoos","Type":"Town","RegionalDistrictCode":"14"},{"Name":"Osoyoos 1","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Penticton","Type":"City","RegionalDistrictCode":"14"},{"Name":"Penticton 1","Type":"Indian reserve","RegionalDistrictCode":"14"},{"Name":"Princeton","Type":"Town","RegionalDistrictCode":"14"},{"Name":"Summerland","Type":"District municipality","RegionalDistrictCode":"14"},{"Name":"Blueberry River 205","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"Chetwynd","Type":"District municipality","RegionalDistrictCode":"28"},{"Name":"Dawson Creek","Type":"City","RegionalDistrictCode":"28"},{"Name":"Doig River 206","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"East Moberly Lake 169","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"Finlay River 6","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"Fort St. John","Type":"City","RegionalDistrictCode":"28"},{"Name":"Fort Ware 1","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"Halfway River 168","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"Hudson''s Hope","Type":"District municipality","RegionalDistrictCode":"28"},{"Name":"Ingenika Point","Type":"Indian settlement","RegionalDistrictCode":"28"},{"Name":"Mesilinka 7","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"Peace River B","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"Name":"Peace River C","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"Name":"Peace River D","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"Name":"Peace River E","Type":"Regional district electoral area","RegionalDistrictCode":"28"},{"Name":"Pouce Coupe","Type":"Village","RegionalDistrictCode":"28"},{"Name":"Taylor","Type":"District municipality","RegionalDistrictCode":"28"},{"Name":"Tumbler Ridge","Type":"District municipality","RegionalDistrictCode":"28"},{"Name":"West Moberly Lake 168A","Type":"Indian reserve","RegionalDistrictCode":"28"},{"Name":"Powell River","Type":"City","RegionalDistrictCode":"7"},{"Name":"qathet A","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"Name":"qathet B","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"Name":"qathet C","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"Name":"qathet D","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"Name":"qathet E","Type":"Regional district electoral area","RegionalDistrictCode":"7"},{"Name":"Sechelt (Part) - qathet","Type":"Indian government district","RegionalDistrictCode":"7"},{"Name":"Sliammon 1","Type":"Tla''amin Lands","RegionalDistrictCode":"7"},{"Name":"Dolphin Island 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"Kulkayu (Hartley Bay) 4","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"Kulkayu (Hartley Bay) 4A","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"Lax Kw''alaams 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"Masset","Type":"Village","RegionalDistrictCode":"24"},{"Name":"Masset 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"North Coast A","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"Name":"North Coast C","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"Name":"North Coast D","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"Name":"North Coast E","Type":"Regional district electoral area","RegionalDistrictCode":"24"},{"Name":"Port Clements","Type":"Village","RegionalDistrictCode":"24"},{"Name":"Port Edward","Type":"District municipality","RegionalDistrictCode":"24"},{"Name":"Prince Rupert","Type":"City","RegionalDistrictCode":"24"},{"Name":"Queen Charlotte","Type":"Village","RegionalDistrictCode":"24"},{"Name":"S1/2 Tsimpsean 2","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"Skidegate 1","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"Tlaa Gaa Aawtlaas 28","Type":"Indian reserve","RegionalDistrictCode":"24"},{"Name":"Bridge River 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Cayoosh Creek 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Cheakamus 11","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Chilhil 6","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 10","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 11","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 12","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 1B","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 1D","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 3","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain 3A","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Fountain Creek 8","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Kowtain 17","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Lillooet","Type":"District municipality","RegionalDistrictCode":"13"},{"Name":"Lillooet 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"McCartney''s Flat 4","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Mission 5","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Mount Currie","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Necait 6","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Nequatque","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Nesikep 6","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Nesuch 3","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Pashilqua 2","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Pavilion 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Pemberton","Type":"Village","RegionalDistrictCode":"13"},{"Name":"Seaichem 16","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Seton Lake 5","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Slosh 1","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Squamish","Type":"District municipality","RegionalDistrictCode":"13"},{"Name":"Squamish-Lillooet A","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"Name":"Squamish-Lillooet B","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"Name":"Squamish-Lillooet C","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"Name":"Squamish-Lillooet D","Type":"Regional district electoral area","RegionalDistrictCode":"13"},{"Name":"Stawamus 24","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Towinock 2","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Waiwakum 14","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Whistler","Type":"District municipality","RegionalDistrictCode":"13"},{"Name":"Yekwaupsum 18","Type":"Indian reserve","RegionalDistrictCode":"13"},{"Name":"Dease River 1","Type":"Indian reserve","RegionalDistrictCode":"27"},{"Name":"Five Mile Point 3","Type":"Indian reserve","RegionalDistrictCode":"27"},{"Name":"Good Hope Lake","Type":"Indian settlement","RegionalDistrictCode":"27"},{"Name":"Liard River 3","Type":"Indian reserve","RegionalDistrictCode":"27"},{"Name":"Lower Post","Type":"Indian settlement","RegionalDistrictCode":"27"},{"Name":"Stikine Region","Type":"Regional district electoral area","RegionalDistrictCode":"27"},{"Name":"Unnamed 10","Type":"Indian reserve","RegionalDistrictCode":"27"},{"Name":"Ahaminaquus 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Campbell River","Type":"City","RegionalDistrictCode":"5"},{"Name":"Campbell River 11","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Cape Mudge 10","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Chenahkint 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Ehatis 11","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Gold River","Type":"Village","RegionalDistrictCode":"5"},{"Name":"Homalco 9","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Houpsitas 6","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Nenagwas 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Oclucje 7","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Quinsam 12","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Sayward","Type":"Village","RegionalDistrictCode":"5"},{"Name":"Squirrel Cove 8","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Strathcona A","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"Name":"Strathcona B","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"Name":"Strathcona C","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"Name":"Strathcona D (Oyster Bay - Buttle Lake)","Type":"Regional district electoral area","RegionalDistrictCode":"5"},{"Name":"Tahsis","Type":"Village","RegionalDistrictCode":"5"},{"Name":"Tork 7","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Tsa Xana 18","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Village Island 1","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Yuquot 1","Type":"Indian reserve","RegionalDistrictCode":"5"},{"Name":"Zeballos","Type":"Village","RegionalDistrictCode":"5"},{"Name":"Chekwelp 26","Type":"Indian reserve","RegionalDistrictCode":"12"},{"Name":"Gibsons","Type":"Town","RegionalDistrictCode":"12"},{"Name":"Sechelt","Type":"District municipality","RegionalDistrictCode":"12"},{"Name":"Sechelt (Part) - Sunshine Coast","Type":"Indian government district","RegionalDistrictCode":"12"},{"Name":"Sunshine Coast A","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"Name":"Sunshine Coast B","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"Name":"Sunshine Coast D","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"Name":"Sunshine Coast E","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"Name":"Sunshine Coast F","Type":"Regional district electoral area","RegionalDistrictCode":"12"},{"Name":"105 Mile Post 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Ashcroft","Type":"Village","RegionalDistrictCode":"15"},{"Name":"Ashcroft 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Barriere","Type":"District municipality","RegionalDistrictCode":"15"},{"Name":"Basque 18","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Bonaparte 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Boothroyd 8A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Cache Creek","Type":"Village","RegionalDistrictCode":"15"},{"Name":"Canoe Creek 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Canoe Creek 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Chase","Type":"Village","RegionalDistrictCode":"15"},{"Name":"Chuchhraischin","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Clearwater","Type":"District municipality","RegionalDistrictCode":"15"},{"Name":"Clinton","Type":"Village","RegionalDistrictCode":"15"},{"Name":"Coldwater 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Douglas Lake 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Entlqwekkinh 19","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Halhalaeden","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Hamilton Creek 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"High Bar 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Inkluckcheen","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Inklyuhkinatko 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Joeyaska 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Kamloops","Type":"City","RegionalDistrictCode":"15"},{"Name":"Kamloops 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Kanaka Bar","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Kitzowit 20","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Klahkamich 17","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Kleetlekut 22","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Klickkumcheen 18","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Kloklowuck 7","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Kumcheen 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Logan Lake","Type":"District municipality","RegionalDistrictCode":"15"},{"Name":"Louis Creek 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Lower Hat Creek 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Lytton","Type":"Village","RegionalDistrictCode":"15"},{"Name":"Lytton 4A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Lytton 4E","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Lytton 9A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Lytton 9B","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Marble Canyon 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Merritt","Type":"City","RegionalDistrictCode":"15"},{"Name":"Nekalliston 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nekliptum 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Neskonlith","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nickel Palm 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nickeyeah 25","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nicola Lake 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nicola Mameet 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nicomen 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nkaih 10","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nohomeen 23","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nooaitch 10","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"North Thompson 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Nuuautin 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Oregon Jack Creek 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Papyum 27","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Paska Island 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Paul''s Basin 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Pemynoos 9","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Peq-Paq 22","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Sahhaltkum 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Seah 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Shackan 11","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Shawniken 4B","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Siska Flat","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Skeetchestn","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Skuppah 2A","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Skuppah 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Skwayaynope 26","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Spences Bridge","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Spintlum Flat 3","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Squaam 2","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Stequmwhulpa 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Stryen 9","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Sun Peaks Mountain","Type":"Village","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola A (Wells Gray Country)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola B (Thompson Headwaters)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola E (Bonaparte Plateau)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola I (Blue Sky Country)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola J (Copper Desert Country)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola L (Grasslands)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola M (Beautiful Nicola Valley - North)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola N (Beautiful Nicola Valley - South)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola O (Lower North Thompson)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Thompson-Nicola P (Rivers and the Peaks)","Type":"Regional district electoral area","RegionalDistrictCode":"15"},{"Name":"Tsaukan 12","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Twoyqhalsht 16","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Upper Hat Creek 1","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Upper Nepa 6","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Whispering Pines 4","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Yawaucht 11","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Zacht 5","Type":"Indian reserve","RegionalDistrictCode":"15"},{"Name":"Zoht 4","Type":"Indian reserve","RegionalDistrictCode":"15"}]'; +-- Cleanup existing data in "Communities" table + DELETE FROM public."Communities"; +-- Insert into "Communities" table. + json_data jsonb := '[{"Name" : "Becher Bay 1", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Central Saanich", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Cole Bay 3", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Colwood", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "East Saanich 2", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Esquimalt", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Esquimalt", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Galiano Island 9", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Gordon River 2", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Highlands", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Juan de Fuca (Part 1)", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Juan de Fuca (Part 2)", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Langford", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Metchosin", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "New Songhees 1A", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "North Saanich", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Oak Bay", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Saanich", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Saltspring Island", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Sidney", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Sooke", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Southern Gulf Islands", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "South Saanich 1", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "T''Sou-ke", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Union Bay 4", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Victoria", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "View Royal", "Type" : "", "RegionalDistrictCode" : "1"}, {"Name" : "Abbotsford", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Aitchelitch 9", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Albert Flat 5", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Boothrouyd 5B", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Boothroyd 13", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Boston Bar 1A", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Bucktum 4", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Chawathil 4", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Cheam 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Chehalis 5", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Chilliwack", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Coqualeetza", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Douglas 8", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley A", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley B", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley C", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley D", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley E", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley F", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley G", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Fraser Valley H", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Harrison Hot Springs", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Holachten 8", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Hope", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Inkahtsaph 6", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Kahmoose 4", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Kent", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Kopchitchin 2", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Kwawkwawapilt 6", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Lakahahmen 11", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Langley 2", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Lukseetsissum 9", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Matsqui Main 2", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Mission", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Ohamil 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Paqulh", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Peters 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Popkum 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Puckatholetchin 11", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Q''alatkú7em", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Ruby Creek 2", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Sachteen", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Saddle Rock 9", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Schelowat 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Schkam 2", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Scowlitz 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Seabird Island", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skawahlook 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skookumchuck 4", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skookumchuck 4A", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skowkale", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skwah 4", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skwali 3", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skway 5", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Skweahm 10", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Soowahlie 14", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Speyum 3", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Spuzzum 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Squawkum Creek 3", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Squiaala", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Stullawheets 8", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Tipella 7", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Tseatah 2", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Tuckkwiowhum 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Tzeachten 13", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Upper Sumas 6", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Yakweakwioose 12", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Yale Town 1", "Type" : "", "RegionalDistrictCode" : "10"}, {"Name" : "Anmore", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Barnston Island 3", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Belcarra", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Bowen Island", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Burnaby", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Burrard Inlet 3", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Capilano 5", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Coquitlam", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Coquitlam 1", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Coquitlam 2", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Delta", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Katzie 1", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Katzie 2", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Kitsilano 6", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Langley", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Langley", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Lions Bay", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Maple Ridge", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Matsqui 4", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "McMillan Island 6", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Metro Vancouver A", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Mission 1", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Musqueam 2", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Musqueam 4", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "New Westminster", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "North Vancouver", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "North Vancouver", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Pitt Meadows", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Port Coquitlam", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Port Moody", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Richmond", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Semiahmoo", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Seymour Creek 2", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Surrey", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Tsawwassen", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Vancouver", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "West Vancouver", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "White Rock", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Whonnock 1", "Type" : "", "RegionalDistrictCode" : "11"}, {"Name" : "Chekwelp 26", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Gibsons", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Sechelt", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Sechelt (Part)", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Sunshine Coast A", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Sunshine Coast B", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Sunshine Coast D", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Sunshine Coast E", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Sunshine Coast F", "Type" : "", "RegionalDistrictCode" : "12"}, {"Name" : "Bridge River 1", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Cayoosh Creek 1", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Cheakamus 11", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Chilhil 6", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 1", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 10", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 11", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 12", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 1B", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 1D", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 3", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain 3A", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Fountain Creek 8", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Kowtain 17", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Lillooet", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Lillooet 1", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "McCartney''s Flat 4", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Mission 5", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Mount Currie", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Necait 6", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Nequatque", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Nesikep 6", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Nesuch 3", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Pashilqua 2", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Pavilion 1", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Pemberton", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Seaichem 16", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Seton Lake 5", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Slosh 1", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Squamish", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Squamish-Lillooet A", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Squamish-Lillooet B", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Squamish-Lillooet C", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Squamish-Lillooet D", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Stawamus 24", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Towinock 2", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Waiwakum 14", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Whistler", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Whitecap 1", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Yekwaupsum 18", "Type" : "", "RegionalDistrictCode" : "13"}, {"Name" : "Alexis 9", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Ashnola 10", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Blind Creek 6", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Chopaka 7 & 8", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Chuchuwayha 2", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Keremeos", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Lower Similkameen 2", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Lulu 5", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen A", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen B", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen C", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen D", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen E", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen F", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen G", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen H", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Okanagan-Similkameen I", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Oliver", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Osoyoos", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Osoyoos 1", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Penticton", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Penticton 1", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Princeton", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "Summerland", "Type" : "", "RegionalDistrictCode" : "14"}, {"Name" : "105 Mile Post 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Ashcroft", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Ashcroft 4", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Barriere", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Basque 18", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Bonaparte 3", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Boothroyd 8A", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Cache Creek", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Canoe Creek 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Canoe Creek 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Chase", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Chuchhraischin", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Clearwater", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Clinton", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Coldwater 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Douglas Lake 3", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Entlqwekkinh 19", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Halhalaeden", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Hamilton Creek 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "High Bar 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Inkluckcheen", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Inklyuhkinatko 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Joeyaska 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Kamloops", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Kamloops 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Kanaka Bar", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Kitzowit 20", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Klahkamich 17", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Kleetlekut 22", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Klickkumcheen 18", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Kloklowuck 7", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Kumcheen 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Logan Lake", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Louis Creek 4", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Lower Hat Creek 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Lytton", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Lytton 4A", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Lytton 4E", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Lytton 9A", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Lytton 9B", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Marble Canyon 3", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Merritt", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nekalliston 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nekliptum 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Neskonlith", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nickel Palm 4", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nickeyeah 25", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nicola Lake 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nicola Mameet 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nicomen 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nkaih 10", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nohomeen 23", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nooaitch 10", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "North Thompson 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Nuuautin 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Oregon Jack Creek 5", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Papyum 27", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Paska Island 3", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Paul''s Basin 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Pemynoos 9", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Peq-Paq 22", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Sahhaltkum 4", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Seah 5", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Shackan 11", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Shawniken 4B", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Siska Flat", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Siska Flat 5B", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Skeetchestn", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Skuppah 2A", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Skuppah 4", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Skwayaynope 26", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Spences Bridge", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Spintlum Flat 3", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Squaam 2", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Stequmwhulpa 5", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Stryen 9", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Sun Peaks Mountain", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola A (Wells Gray Country)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola B (Thompson Headwaters)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola E (Bonaparte Plateau)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola I (Blue Sky Country)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola J (Copper Desert Country)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola L (Grasslands)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola M (Beautiful Nicola Valley - North)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola N (Beautiful Nicola Valley - South)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola O (Lower North Thompson)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Thompson-Nicola P (Rivers and the Peaks)", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Toops 3", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Tsaukan 12", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Twoyqhalsht 16", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Upper Hat Creek 1", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Upper Nepa 6", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Whispering Pines 4", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Yawaucht 11", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Zacht 5", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Zoht 4", "Type" : "", "RegionalDistrictCode" : "15"}, {"Name" : "Central Okanagan", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Central Okanagan West", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Duck Lake 7", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Kelowna", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Lake Country", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Peachland", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Tsinstikeptum 10", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Tsinstikeptum 9", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "West Kelowna", "Type" : "", "RegionalDistrictCode" : "16"}, {"Name" : "Armstrong", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Coldstream", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Enderby", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Enderby 2", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Harris 3", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Lumby", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "North Okanagan B", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "North Okanagan C", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "North Okanagan D", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "North Okanagan E", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "North Okanagan F", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Okanagan (Part) 1", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Priest''s Valley 6", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Spallumcheen", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Vernon", "Type" : "", "RegionalDistrictCode" : "17"}, {"Name" : "Chum Creek 2", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Columbia Shuswap A", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Columbia Shuswap B", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Columbia Shuswap C", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Columbia Shuswap D", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Columbia Shuswap E", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Columbia Shuswap F", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Columbia Shuswap G", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Golden", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Hustalen 1", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "North Bay 5", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Okanagan (Part) 1", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Quaaout 1", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Revelstoke", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Salmon Arm", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Salmon River 1", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Scotch Creek 4", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Sicamous", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Switsemalph", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Switsemalph 3", "Type" : "", "RegionalDistrictCode" : "18"}, {"Name" : "Canal Flats", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Cassimayooks (Mayook) 5", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Columbia Lake 3", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Cranbrook", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "East Kootenay A", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "East Kootenay B", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "East Kootenay C", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "East Kootenay E", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "East Kootenay F", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "East Kootenay G", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Elkford", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Fernie", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Invermere", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Isidore''s Ranch 4", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Kimberley", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Kootenay 1", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Radium Hot Springs", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Shuswap", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Sparwood", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "St. Mary''s", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Tobacco Plains 2", "Type" : "", "RegionalDistrictCode" : "19"}, {"Name" : "Chemainus 13", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Lake", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley A", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley B", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley C", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley D", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley E", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley F", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley G", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley H", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Cowichan Valley I", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Duncan", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Est-Patrolas 4", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Halalt 2", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Kil-pah-las 3", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Ladysmith", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Lake Cowichan", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Lyacksun 3", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Malachan 11", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Malahat 11", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "North Cowichan", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Oyster Bay 12", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Penelakut Island 7", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Portier Pass 5", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Shingle Point 4", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Squaw-Hay-One 11", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Theik 2", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Tsussie 6", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Tzart-Lam 5", "Type" : "", "RegionalDistrictCode" : "2"}, {"Name" : "Castlegar", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay A", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay B", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay C", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay D", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay E", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay F", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay G", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay H", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay I", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay J", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Central Kootenay K", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Creston", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Creston 1", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Kaslo", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Lower Kootenay 1C", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Lower Kootenay 5", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Nakusp", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Nelson", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "New Denver", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Salmo", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Silverton", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Slocan", "Type" : "", "RegionalDistrictCode" : "20"}, {"Name" : "Fruitvale", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Grand Forks", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Greenwood", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Kootenay Boundary A", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Kootenay Boundary B / Lower Columbia-Old-Glory", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Kootenay Boundary C / Christina Lake", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Kootenay Boundary D / Rural Grand Forks", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Kootenay Boundary E / West Boundary", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Midway", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Montrose", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Rossland", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Trail", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Warfield", "Type" : "", "RegionalDistrictCode" : "21"}, {"Name" : "Agats Meadow 8", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Alexandria", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Alexis Creek 14", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Alexis Creek 16", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Alexis Creek 21", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Alexis Creek 34", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Alkali Lake 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Alkali Lake 4A", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Anahim''s Flat 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Anahim''s Meadow", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Baezaeko River 27", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Baptiste Meadow 2", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Betty Creek 18", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Canim Lake 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Canim Lake 2", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Canim Lake 4", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Canoe Creek 3", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo A", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo B", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo C", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo D", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo E", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo F", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo G", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo H", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo I", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo J", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo K", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Cariboo L", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Charley Boy''s Meadow 3", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Chilco Lake 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Chilco Lake 1A", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Coglistiko River 29", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Deep Creek 2", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Dog Creek 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Dog Creek 2", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Dragon Lake 3", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Euchinico Creek 17", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Fishtrap 19", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Garden", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Johny Sticks 2", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Kluskus 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Lezbye 6", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Little Springs", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Lohbiee 3", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Michel Gardens 36", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Nazco 20", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "One Hundred Mile House", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Puntzi Lake 2", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Quesnel", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Quesnel 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Redstone Flat 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Redstone Flat 1A", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Salmon River Meadow 7", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Sandy Harry 4", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Soda Creek 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Squinas 2", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Stone 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Sundayman''s Meadow 3", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Swan Lake 3", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Tanakut 4", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Thomas Squinas Ranch 2A", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Toosey 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Towdystan Lake 3", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Trout Lake Alec 16", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Ulkatcho 13", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Ulkatcho 14A", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Wells", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Williams Lake", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Williams Lake 1", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Windy Mouth 7", "Type" : "", "RegionalDistrictCode" : "22"}, {"Name" : "Fort George 2", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Fraser-Fort George A", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Fraser-Fort George C", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Fraser-Fort George D", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Fraser-Fort George E", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Fraser-Fort George F", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Fraser-Fort George G", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Fraser-Fort George H", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Mackenzie", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "McBride", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "McCleod Lake 5", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "McLeod Lake 1", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Prince George", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Valemount", "Type" : "", "RegionalDistrictCode" : "23"}, {"Name" : "Daajing Giids", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Dolphin Island 1", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Kulkayu (Hartley Bay) 4", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Kulkayu (Hartley Bay) 4A", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Lax Kw''alaams 1", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Masset", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Masset 1", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "North Coast A", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "North Coast C", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "North Coast D", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "North Coast E", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Port Clements", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Port Edward", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Prince Rupert", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "S1/2 Tsimpsean 2", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Skidegate 1", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Tlaa Gaa Aawtlaas 28", "Type" : "", "RegionalDistrictCode" : "24"}, {"Name" : "Anlaw 4", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Babine 17", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Bulkley River 19", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Coryatsaqua (Moricetown) 2", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Dease Lake 9", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Gitanmaax 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Gitanyow 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Gitsegukla 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Gitwangak 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Guhthe Tah 12", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Hagwilget 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Hazelton", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Iskut 6", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kispiox 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitamaat 2", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitasoo 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat-Stikine A", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat-Stikine B", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat-Stikine C (Part 1)", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat-Stikine C (Part 2)", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat-Stikine D", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat-Stikine E", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitimat-Stikine F", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitselas 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kitsumkaylum 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kshish 4", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Kulspai 6", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Moricetown 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "New Hazelton", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Nisga''a", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Sik-e-dakh 2", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Stewart", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Tahltan 1", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Telegraph Creek", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Terrace", "Type" : "", "RegionalDistrictCode" : "25"}, {"Name" : "Babine 16", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Babine 25", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Babine 6", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Babine Lake 21B", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Binche 2", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Bulkley-Nechako A", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Bulkley-Nechako B", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Bulkley-Nechako C", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Bulkley-Nechako D", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Bulkley-Nechako E", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Bulkley-Nechako F", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Bulkley-Nechako G", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Burns Lake", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Burns Lake 18", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Cheslatta 1", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Duncan Lake 2", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Dzitline Lee 9", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Fort St. James", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Francois Lake 7", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Fraser Lake", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Granisle", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Houston", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Jean Baptiste 28", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Laketown 3", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Mission Lands 17", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Nak''azdli", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Nautley (Fort Fraser) 1", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Nedoats 11", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Noonla 6", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "North Tacla Lake", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Omineca 1", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Palling 1", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Poison Creek 17A", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Seaspunkut 4", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Skins Lake 16A", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Skins Lake 16B", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Smithers", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Sowchea 3", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Stellaquo (Stella) 1", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Stony Creek 1", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Tache 1", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Tacla Lake (Ferry Landing) 9", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Tadinlay 15", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Tatla''t East 2", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Tatla West 11", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Telkwa", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Uncha Lake 13A", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Vanderhoof", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Williams Prairie Meadow 1A", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Woyenne 27", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Ye Koo Che 3", "Type" : "", "RegionalDistrictCode" : "26"}, {"Name" : "Dease River 1", "Type" : "", "RegionalDistrictCode" : "27"}, {"Name" : "Five Mile Point 3", "Type" : "", "RegionalDistrictCode" : "27"}, {"Name" : "Good Hope Lake", "Type" : "", "RegionalDistrictCode" : "27"}, {"Name" : "Liard River 3", "Type" : "", "RegionalDistrictCode" : "27"}, {"Name" : "Lower Post", "Type" : "", "RegionalDistrictCode" : "27"}, {"Name" : "Stikine Region", "Type" : "", "RegionalDistrictCode" : "27"}, {"Name" : "Unnamed 10", "Type" : "", "RegionalDistrictCode" : "27"}, {"Name" : "Blueberry River 205", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Chetwynd", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Dawson Creek", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Doig River 206", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "East Moberly Lake 169", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Finlay River 6", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Fort St. John", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Fort Ware 1", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Halfway River 168", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Hudson''s Hope", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Ingenika Point", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Mesilinka 7", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Peace River B", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Peace River C", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Peace River D", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Peace River E", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Pouce Coupe", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Taylor", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Tumbler Ridge", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "West Moberly Lake 168A", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Wochiigii Nané?", "Type" : "", "RegionalDistrictCode" : "28"}, {"Name" : "Fontas 1", "Type" : "", "RegionalDistrictCode" : "29"}, {"Name" : "Fort Nelson 2", "Type" : "", "RegionalDistrictCode" : "29"}, {"Name" : "Kahntah 3", "Type" : "", "RegionalDistrictCode" : "29"}, {"Name" : "Northern Rockies", "Type" : "", "RegionalDistrictCode" : "29"}, {"Name" : "Prophet River 4", "Type" : "", "RegionalDistrictCode" : "29"}, {"Name" : "Lantzville", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo A", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo B", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo C", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo E", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo F", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo G", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo H", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo River", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanaimo Town 1", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Nanoose", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Parksville", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Qualicum", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Qualicum Beach", "Type" : "", "RegionalDistrictCode" : "3"}, {"Name" : "Ahahswinis 1", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Alberni 2", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Alberni-Clayoquot A", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Alberni-Clayoquot B", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Alberni-Clayoquot C", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Alberni-Clayoquot D", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Alberni-Clayoquot E", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Alberni-Clayoquot F", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Anacla 12", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Clakamucus 2", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Elhlateese 2", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Esowista 3", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Hesquiat 1", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Ittatsoo 1", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Klehkoot 2", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Macoah 1", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Marktosis 15", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Numukamis 1", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Opitsat 1", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Port Alberni", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Refuge Cove 6", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Sachsa 4", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Tin Wis 11", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Tofino", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Tsahaheh 1", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Ucluelet", "Type" : "", "RegionalDistrictCode" : "4"}, {"Name" : "Ahaminaquus 12", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Campbell River", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Campbell River 11", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Cape Mudge 10", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Chenahkint 12", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Ehatis 11", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Gold River", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Homalco 9", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Houpsitas 6", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Nenagwas 12", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Oclucje 7", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Quinsam 12", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Sayward", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Squirrel Cove 8", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Strathcona A", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Strathcona B", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Strathcona C", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Strathcona D (Oyster Bay - Buttle Lake)", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Tahsis", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Tork 7", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Tsa Xana 18", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Village Island 1", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Yuquot 1", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Zeballos", "Type" : "", "RegionalDistrictCode" : "5"}, {"Name" : "Comox", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Comox 1", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Comox Valley A", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Comox Valley B (Lazo North)", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Comox Valley C (Puntledge - Black Creek)", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Courtenay", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Cumberland", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Pentledge 2", "Type" : "", "RegionalDistrictCode" : "6"}, {"Name" : "Powell River", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "qathet A", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "qathet B", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "qathet C", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "qathet D", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "qathet E", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "Sechelt (Part)", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "Sliammon 1", "Type" : "", "RegionalDistrictCode" : "7"}, {"Name" : "Alert Bay", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Alert Bay", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Dead Point 5", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Fort Rupert 1", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Gwayasdums 1", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Hope Island 1", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Hopetown 10A", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Kippase 2", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Mount Waddington A", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Mount Waddington B", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Mount Waddington C", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Mount Waddington D", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Nimpkish 2", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Port Alice", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Port Hardy", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Port McNeill", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Quaee 7", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Quatsino Subdivision 18", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Thomas Point 5", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Thomas Point 5A", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Tsulquate 4", "Type" : "", "RegionalDistrictCode" : "8"}, {"Name" : "Bella Bella 1", "Type" : "", "RegionalDistrictCode" : "9"}, {"Name" : "Bella Coola 1", "Type" : "", "RegionalDistrictCode" : "9"}, {"Name" : "Central Coast A", "Type" : "", "RegionalDistrictCode" : "9"}, {"Name" : "Central Coast C", "Type" : "", "RegionalDistrictCode" : "9"}, {"Name" : "Central Coast D", "Type" : "", "RegionalDistrictCode" : "9"}, {"Name" : "Central Coast E", "Type" : "", "RegionalDistrictCode" : "9"}, {"Name" : "Katit 1", "Type" : "", "RegionalDistrictCode" : "9"}]'; BEGIN -- Insert into "Communities" table INSERT INTO public."Communities" @@ -16,4 +19,4 @@ BEGIN pg_catalog.now() FROM jsonb_array_elements(json_data::jsonb) AS data; -END $$; +END $$; \ No newline at end of file diff --git a/database/unity-backup-cronjob.yaml b/database/unity-backup-cronjob.yaml deleted file mode 100644 index e0ed02caf..000000000 --- a/database/unity-backup-cronjob.yaml +++ /dev/null @@ -1,167 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -message: |- - A scheduled cronjob has been created in your project: unity-backup. - For more information about using this template, including OpenShift considerations, - see template usage guide found in the project readme.md and wiki documents. -metadata: - name: unity-backup-cronjob - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\database\unity-backup-cronjob.yaml --param-file=backup-cronjob.env | oc create -f - - labels: - template: unity-backup-cronjob - annotations: - description: |- - Template for running a recurring backup script in OpenShift. - iconClass: icon-build - openshift.io/display-name: Database Backup Cronjob - template.openshift.io/long-description: |- - This template defines resources needed to run a Postgres-16 container application. - tags: database,postgresql -parameters: -# Project namespace parameters -- description: The name of the backup application. - displayName: Application Name - name: APPLICATION_NAME - required: true - value: unity-backup-cronjob -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - required: true - value: unity-grantmanager -# Additional parameters for project database provisioning. -- description: The name of the OpenShift Service exposed for the database. - displayName: Database Service Name - name: DATABASE_SERVICE_NAME - required: true - value: unity-data-postgres -- name: DATABASE_BACKUP_KEEP - description: 'Number of backups to keep' - value: '1' -- name: DATABASE_BACKUP_SCHEDULE - description: 'Cron-like schedule expression m h D M DayOfWeek add +7/8 hours for UTC conversions' - required: true - value: '0 14 * * *' -- name: DATABASE_BACKUP_VOLUME_CLAIM - description: 'Name of the volume claim to be used as storage' - required: true - value: unity-data-backup -- description: The name of the storage object. - displayName: Object Storage Name - name: STORAGE_OBJECT_NAME - required: true - value: s3-object-storage -- description: The Namespace where the container image resides default=project-tools cluster=openshift, source=registry.redhat.io/rhel9/postgresql-16 - displayName: Registry Namespace - name: IMAGEPULL_NAMESPACE - from: '[a-zA-Z0-9]{5}-tools' - generate: expression -- description: The Openshift ImageStream Name - displayName: Registry imagestream name - name: IMAGESTREAM_NAME - required: true - value: postgresql-16 -- description: The version of the postgresql container image to use. - displayName: Registry container image to pull - name: IMAGESTREAM_TAG - required: true - value: latest -- description: The registry path of the postgresql container image to use. - displayName: Registry container image to pull - name: IMAGEPULL_REGISTRY - required: true - value: image-registry.apps.silver.devops.gov.bc.ca -# Resource limits -- description: The minimum amount of CPU the container is guaranteed. - displayName: CPU Request - name: CPU_REQUEST - required: true - value: 50m -- description: The minimum amount of memory the container is guaranteed. - displayName: Memory Request - name: MEMORY_REQUEST - required: true - value: 64Mi -# Template objects to instantiate the project. -objects: -# Recurring cronjob for Database Backups -- apiVersion: batch/v1 - kind: CronJob - metadata: - name: ${APPLICATION_NAME} - labels: - job-name: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - # Cronjob script works with both database or instance backup commands - # pg_dump --username=$UNITY_POSTGRES_USER --host=$UNITY_DB_HOST --port=$UNITY_DB_PORT --column-inserts --clean --create ${DATABASE_SERVICE_NAME} - # pg_dumpall --username=$UNITY_POSTGRES_USER --host=$UNITY_DB_HOST --port=$UNITY_DB_PORT --column-inserts --clean - spec: - schedule: ${DATABASE_BACKUP_SCHEDULE} - concurrencyPolicy: Forbid - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 1 - jobTemplate: - spec: - template: - spec: - volumes: - - name: ${APPLICATION_NAME} - persistentVolumeClaim: - claimName: ${DATABASE_BACKUP_VOLUME_CLAIM} - containers: - - name: ${APPLICATION_NAME} - image: ${IMAGEPULL_REGISTRY}/${IMAGEPULL_NAMESPACE}/${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG} - command: - - 'bash' - - '-eo' - - 'pipefail' - - '-c' - - > - trap "echo Backup failed; exit 0" ERR; date; - FILENAME=dumpall-${DATABASE_SERVICE_NAME}-`date +%Y-%m-%d_%H%M%S`.sql.gz; - time (find /var/lib/pgsql/backups -type f -name "*-${DATABASE_SERVICE_NAME}-*" -exec ls -1tr "{}" + | head -n -$DATABASE_BACKUP_KEEP | xargs rm -fr; - PGPASSWORD="$UNITY_POSTGRES_PASSWORD" pg_dumpall --username=$UNITY_POSTGRES_USER --host=$UNITY_DB_HOST --port=$UNITY_DB_PORT --column-inserts --clean | gzip > /var/lib/pgsql/backups/$FILENAME); - echo "";echo "Backup successful";du -h /var/lib/pgsql/backups/$FILENAME; - echo "to restore the backup use: $ psql --username=$UNITY_POSTGRES_USER --password --host=$UNITY_DB_HOST --port=$UNITY_DB_PORT --username postgres < /var/lib/pgsql/backups/ (unpacked with gunzip)"; - echo "";/var/lib/pgsql/backups/bin/mc alias set $AccessKeyID $RestEndpoint $AccessKeyID $SecretKey;/var/lib/pgsql/backups/bin/mc mirror --remove --summary /var/lib/pgsql/backups $AccessKeyID/$BucketDisplayName/Unity/Backups; - echo "";ls -lR /var/lib/pgsql/backups - env: - - name: RestEndpoint - valueFrom: - configMapKeyRef: - name: ${STORAGE_OBJECT_NAME} - key: S3__Endpoint - - name: AccessKeyID - valueFrom: - secretKeyRef: - name: ${STORAGE_OBJECT_NAME} - key: S3__AccessKeyId - - name: BucketDisplayName - valueFrom: - secretKeyRef: - name: ${STORAGE_OBJECT_NAME} - key: S3__Bucket - - name: SecretKey - valueFrom: - secretKeyRef: - name: ${STORAGE_OBJECT_NAME} - key: S3__SecretAccessKey - - name: DATABASE_BACKUP_KEEP - value: ${DATABASE_BACKUP_KEEP} - - name: TZ - value: Canada/Pacific - envFrom: - - secretRef: - name: ${DATABASE_SERVICE_NAME} - volumeMounts: - - name: ${APPLICATION_NAME} - mountPath: /var/lib/pgsql/backups - resources: - requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} - restartPolicy: Never diff --git a/database/unity-database.yaml b/database/unity-database.yaml deleted file mode 100644 index d7555a68e..000000000 --- a/database/unity-database.yaml +++ /dev/null @@ -1,239 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -message: |- - A new application been created in your project: unity-database - For more information about using this template, including OpenShift considerations, - see template usage guide found in the project readme.md and wiki documents. -metadata: - name: unity-database - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\database\unity-database.yaml --param-file=unity-database.env | oc create -f - - labels: - template: unity-database - annotations: - description: |- - PostgreSQL database service with persistent storage. - NOTE: Scaling to more than one replica is not supported. - iconClass: icon-postgresql - openshift.io/display-name: PostgreSQL - openshift.io/long-description: This template provides a standalone PostgreSQL - server with an initial database created. The database is stored on persistent storage. - The database name, username, and password are selected through parameters during provisioning. - tags: database,postgresql -parameters: -# Project namespace parameters -- description: The name of the backup application. - displayName: Application Name - name: APPLICATION_NAME - required: false - value: unity-databaase -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - required: true - value: unity-tools -# Additional parameters for project database provisioning. -- description: The name of the OpenShift Service exposed for the database. - displayName: Database Service Name - name: DATABASE_SERVICE_NAME - required: true - value: unity-database -- description: The port exposed for the database. - displayName: Database Service Port - name: DATABASE_PORT - required: true - value: "5432" -- description: Username for PostgreSQL user that will be used for accessing the database. - displayName: PostgreSQL Connection Username - name: POSTGRESQL_USER - required: false - value: "postgres" -- description: Password for the PostgreSQL connection user. - displayName: PostgreSQL Connection Password - name: POSTGRESQL_PASSWORD - required: false - from: '[a-zA-Z0-9]{26}' - generate: expression -- description: Name of the PostgreSQL database accessed. - displayName: PostgreSQL Database Name - name: POSTGRESQL_DATABASE - required: true - value: postgres -- description: Volume space for data directory. - displayName: Volume Capacity - name: VOLUME_CAPACITY - required: true - value: 256Mi -- description: The Namespace where the container image resides default=project-tools cluster=openshift, source=registry.redhat.io/rhel9/postgresql-16 - displayName: Registry Namespace - name: IMAGEPULL_NAMESPACE - from: '[a-zA-Z0-9]{5}-tools' - generate: expression -- description: The Openshift ImageStream Name - displayName: Registry imagestream name - name: IMAGESTREAM_NAME - required: true - value: postgresql-16 -- description: The version of the postgresql container image to use. - displayName: Registry container image to pull - name: IMAGESTREAM_TAG - required: true - value: latest -- description: The registry path of the postgresql container image to use. - displayName: Registry container image to pull - name: IMAGEPULL_REGISTRY - required: true - value: image-registry.apps.silver.devops.gov.bc.ca -# Resource limits -- description: The minimum amount of CPU the container is guaranteed. - displayName: CPU Request - name: CPU_REQUEST - required: true - value: 50m -- description: The minimum amount of memory the container is guaranteed. - displayName: Memory Request - name: MEMORY_REQUEST - required: true - value: 64Mi -# Template objects to instantiate the project. -objects: -# Secrets -- apiVersion: v1 - kind: Secret - metadata: - annotations: - template.openshift.io/expose-database_name: '{.data[''POSTGRES_DATABASE'']}' - template.openshift.io/expose-password: '{.data[''POSTGRES_PASSWORD'']}' - template.openshift.io/expose-username: '{.data[''POSTGRES_USER'']}' - name: ${DATABASE_SERVICE_NAME} - labels: - app: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/component: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/instance: ${DATABASE_SERVICE_NAME}-1 - app.kubernetes.io/name: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - stringData: - POSTGRES_USER: ${POSTGRESQL_USER} - POSTGRES_DATABASE: ${POSTGRESQL_DATABASE} - POSTGRES_PASSWORD: ${POSTGRESQL_PASSWORD} - type: Opaque -# Service -- apiVersion: v1 - kind: Service - metadata: - annotations: - template.openshift.io/expose-uri: postgres://{.spec.clusterIP}:{.spec.ports[?(.name=="postgresql")].port} - name: ${DATABASE_SERVICE_NAME} - labels: - app: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/component: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/instance: ${DATABASE_SERVICE_NAME}-1 - app.kubernetes.io/name: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - ports: - - name: ${DATABASE_SERVICE_NAME} - nodePort: 0 - protocol: TCP - port: ${{DATABASE_PORT}} - targetPort: ${{DATABASE_PORT}} - selector: - app: ${DATABASE_SERVICE_NAME} - sessionAffinity: None - type: ClusterIP - status: - loadBalancer: {} -# Persistent storage for database backups -- apiVersion: v1 - kind: PersistentVolumeClaim - metadata: - name: unity-data-backup - labels: - app: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/component: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/instance: ${DATABASE_SERVICE_NAME}-1 - app.kubernetes.io/name: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: ${VOLUME_CAPACITY} - storageClassName: netapp-file-backup - volumeMode: Filesystem -# Deployment -- apiVersion: apps/v1 - kind: Deployment - metadata: - annotations: - template.alpha.openshift.io/wait-for-ready: "true" - # Add the trigger annotation - image.openshift.io/triggers: >- - [{"from":{"kind":"ImageStreamTag","name":"${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG}","namespace":"${IMAGEPULL_NAMESPACE}"},"fieldPath":"spec.template.spec.containers[?(@.name==\"${{DATABASE_SERVICE_NAME}\")].image","pause":"false"}] - name: ${DATABASE_SERVICE_NAME} - labels: - app: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/component: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/instance: ${DATABASE_SERVICE_NAME}-1 - app.kubernetes.io/name: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - replicas: 1 - selector: - matchLabels: - app: ${DATABASE_SERVICE_NAME} - template: - metadata: - labels: - app: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/component: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/instance: ${DATABASE_SERVICE_NAME}-1 - app.kubernetes.io/name: ${DATABASE_SERVICE_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - containers: - - name: ${DATABASE_SERVICE_NAME} - image: ${IMAGEPULL_REGISTRY}/${IMAGEPULL_NAMESPACE}/${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG} - ports: - - containerPort: ${{DATABASE_PORT}} - protocol: TCP - env: - - name: POSTGRESQL_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: ${DATABASE_SERVICE_NAME} - key: POSTGRES_PASSWORD - livenessProbe: - exec: - command: - - /usr/libexec/check-container - - --live - initialDelaySeconds: 120 - timeoutSeconds: 10 - readinessProbe: - exec: - command: - - /usr/libexec/check-container - initialDelaySeconds: 5 - periodSeconds: 60 - timeoutSeconds: 1 - resources: - requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} - terminationMessagePath: /dev/termination-log - envFrom: - - secretRef: - name: ${DATABASE_SERVICE_NAME} - volumeMounts: - - mountPath: /var/lib/pgsql/backups - name: unity-data-backups - dnsPolicy: ClusterFirst - restartPolicy: Always - volumes: - - name: unity-data-backups - persistentVolumeClaim: - claimName: unity-data-backup - strategy: - type: Recreate diff --git a/applications/Unity.GrantManager/docs/ApplicantPortalIntegration.md b/documentation/applicant-portal/applicant-portal-integration.md similarity index 94% rename from applications/Unity.GrantManager/docs/ApplicantPortalIntegration.md rename to documentation/applicant-portal/applicant-portal-integration.md index c47190351..4595cffcd 100644 --- a/applications/Unity.GrantManager/docs/ApplicantPortalIntegration.md +++ b/documentation/applicant-portal/applicant-portal-integration.md @@ -69,7 +69,7 @@ X-Api-Key: {your-api-key} ### 1. Get Applicant Profile -Retrieves basic profile information for an applicant. +Retrieves applicant profile data based on the specified key. The response `data` property is polymorphic and varies by key. See [Applicant Profile Data Providers](./applicant-profile-data-providers.md) for full details on each provider. **Endpoint**: `GET /api/app/applicant-profiles/profile` @@ -79,20 +79,33 @@ Retrieves basic profile information for an applicant. | `ProfileId` | `Guid` | Yes | Unique identifier for the applicant profile | | `Subject` | `string` | Yes | OIDC subject identifier (e.g., `user@idp`) | | `TenantId` | `Guid` | Yes | The tenant ID to query within | +| `Key` | `string` | Yes | The data type to retrieve: `CONTACTINFO`, `ADDRESSINFO`, `SUBMISSIONINFO`, `ORGINFO`, `PAYMENTINFO` | **Request Example**: ```http -GET /api/app/applicant-profiles/profile?ProfileId=3fa85f64-5717-4562-b3fc-2c963f66afa6&Subject=smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299&TenantId=7c9e6679-7425-40de-944b-e07fc1f90ae7 +GET /api/app/applicant-profiles/profile?ProfileId=3fa85f64-5717-4562-b3fc-2c963f66afa6&Subject=smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299&TenantId=7c9e6679-7425-40de-944b-e07fc1f90ae7&Key=CONTACTINFO X-Api-Key: your-api-key-here ``` -**Response Example** (200 OK): +**Response Example** (200 OK — `Key=CONTACTINFO`): ```json { "profileId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "subject": "smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299", - "email": "applicant@example.com", - "displayName": "John Doe" + "key": "CONTACTINFO", + "tenantId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "data": { + "dataType": "CONTACTINFO", + "contacts": [ + { + "contactId": "a1b2c3d4-...", + "name": "John Doe", + "email": "applicant@example.com", + "contactType": "ApplicantProfile", + "isEditable": true + } + ] + } } ``` @@ -102,11 +115,14 @@ public class ApplicantProfileDto { public Guid ProfileId { get; set; } public string Subject { get; set; } - public string Email { get; set; } - public string DisplayName { get; set; } + public string Key { get; set; } + public Guid TenantId { get; set; } + public ApplicantProfileDataDto? Data { get; set; } // Polymorphic — varies by Key } ``` +The `Data` property uses a JSON discriminator (`dataType`) for polymorphic deserialization. See [Applicant Profile Data Providers](./applicant-profile-data-providers.md) for the complete schema of each data type. + --- ### 2. Get Applicant Tenants @@ -159,25 +175,32 @@ public class ApplicantTenantDto ## Subject Identifier Format -The system extracts and normalizes OIDC subject identifiers as follows: +The system extracts and normalizes OIDC subject identifiers as follows. For full extraction logic including the CHEFS form prerequisite and token structure, see [OIDC Subject Ingestion from CHEFS](./applicant-profile-data-providers.md#oidc-subject-ingestion-from-chefs). ### Input Formats Supported -1. **From CHEFS Submission**: + +Search paths are checked in priority order until a non-empty value is found: + +1. **From CHEFS Submission (primary)**: + - Path: `submission.data.applicantAgent.sub` + - Example: `"smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299"` + +2. **From CHEFS Submission (alternate)**: - Path: `submission.data.hiddenApplicantAgent.sub` - Example: `"smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299"` -2. **From CreatedBy Field**: - - Path: `submission.createdBy` +3. **From CreatedBy Field (fallback)**: + - Path: `createdBy` - Example: `"anonymous@bcservicescard"` ### Normalization Rules 1. Extract the identifier **before** the `@` symbol 2. Convert to **UPPERCASE** -3. Store in `AppApplicantTenantMaps.OidcSubUsername` +3. Store in `AppApplicantTenantMaps.OidcSubUsername` and `ApplicationFormSubmission.OidcSub` **Examples**: -- `smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299` ? `SMZFRRLA7J5HW6Z7WZVYZDRTQ6DJ6FBR` -- `anonymous@bcservicescard` ? `ANONYMOUS` +- `smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299` --> `SMZFRRLA7J5HW6Z7WZVYZDRTQ6DJ6FBR` +- `anonymous@bcservicescard` --> `ANONYMOUS` **Implementation**: See `IntakeSubmissionHelper.ExtractOidcSub(dynamic submission)` @@ -472,7 +495,7 @@ graph TB ### Message Flow Patterns -#### 1. Commands (Applicant Portal ? Unity) +#### 1. Commands (Applicant Portal --> Unity) Commands represent requests from the Applicant Portal for Unity to perform an action. @@ -513,7 +536,7 @@ sequenceDiagram - Trigger: User requests status update - Action: Unity publishes current status event -#### 2. Events (Unity ? Applicant Portal) +#### 2. Events (Unity --> Applicant Portal) Events represent notifications about things that have happened in Unity. @@ -1087,13 +1110,13 @@ public class EncryptedMessage #### Planned Message Types -**Commands** (Portal ? Unity): +**Commands** (Portal --> Unity): - `CreateApplicationDraftCommand` - `UploadDocumentCommand` - `WithdrawApplicationCommand` - `RequestApplicationReviewCommand` -**Events** (Unity ? Portal): +**Events** (Unity --> Portal): - `ReviewerAssignedEvent` - `AssessmentCompletedEvent` - `FundingAgreementGeneratedEvent` @@ -1549,7 +1572,7 @@ For issues or questions: ## Related Documentation -- [Applicant Tenant Mapping Implementation](./ApplicantTenantMapping.md) - Technical implementation details +- [Applicant Profile Data Providers](./applicant-profile-data-providers.md) - Provider strategy, data flow diagrams, and OIDC subject extraction details - [API Key Authentication](../src/Unity.GrantManager.HttpApi/Controllers/Authentication/README.md) - Authentication setup - [Background Jobs](../src/Unity.GrantManager.Application/HealthChecks/BackgroundWorkers/README.md) - Background worker configuration diff --git a/documentation/applicant-portal/applicant-profile-data-providers.md b/documentation/applicant-portal/applicant-profile-data-providers.md new file mode 100644 index 000000000..438f7a1bb --- /dev/null +++ b/documentation/applicant-portal/applicant-profile-data-providers.md @@ -0,0 +1,572 @@ +# Applicant Profile Data Providers + +## Overview + +The Applicant Profile system exposes a single polymorphic API endpoint that returns different data shapes depending on a **key** parameter. The controller delegates to `ApplicantProfileAppService`, which resolves the correct `IApplicantProfileDataProvider` implementation using a strategy/dictionary pattern. + +All providers are registered via ABP's `[ExposeServices]` attribute and collected as `IEnumerable` in the app service constructor, where they are indexed by their `Key` property. + +--- + +## Entry Point + +**Endpoint:** `GET /api/app/applicant-profiles/profile` + +**Authentication:** API Key (via `ApiKeyAuthorizationFilter`) + +**Query Parameters** (`ApplicantProfileInfoRequest`): + +| Parameter | Type | Description | +|-------------|--------|--------------------------------------------------------------| +| `ProfileId` | `Guid` | The applicant profile identifier | +| `Subject` | `string` | The OIDC subject (e.g. `user@idir`) | +| `TenantId` | `Guid` | The tenant to scope the query to | +| `Key` | `string` | The provider key — determines which data type is returned | + +**Supported Keys:** + +| Key | Provider Class | DTO Returned | Status | +|------------------|------------------------------|-------------------------------|-----------------| +| `CONTACTINFO` | `ContactInfoDataProvider` | `ApplicantContactInfoDto` | ✅ Implemented | +| `ADDRESSINFO` | `AddressInfoDataProvider` | `ApplicantAddressInfoDto` | ✅ Implemented | +| `SUBMISSIONINFO` | `SubmissionInfoDataProvider` | `ApplicantSubmissionInfoDto` | ✅ Implemented | +| `ORGINFO` | `OrgInfoDataProvider` | `ApplicantOrgInfoDto` | ⬜ Placeholder | +| `PAYMENTINFO` | `PaymentInfoDataProvider` | `ApplicantPaymentInfoDto` | ⬜ Placeholder | + +**Response:** `ApplicantProfileDto` with a polymorphic `Data` property (JSON discriminator: `dataType`). + +--- + +## High-Level Architecture + +```mermaid +graph TB + Client([External Client]) + Controller["ApplicantProfileController
GET /api/app/applicant-profiles/profile"] + Filter["ApiKeyAuthorizationFilter"] + AppService["ApplicantProfileAppService"] + ProviderDict["Provider Dictionary
key to IApplicantProfileDataProvider"] + + Client -->|"HTTP GET ?Key=..."| Controller + Controller --> Filter + Filter -->|Authorized| AppService + AppService -->|"Lookup by Key"| ProviderDict + + ProviderDict --> ContactProvider["ContactInfoDataProvider
CONTACTINFO"] + ProviderDict --> AddressProvider["AddressInfoDataProvider
ADDRESSINFO"] + ProviderDict --> SubmissionProvider["SubmissionInfoDataProvider
SUBMISSIONINFO"] + ProviderDict --> OrgProvider["OrgInfoDataProvider
ORGINFO
placeholder"] + ProviderDict --> PaymentProvider["PaymentInfoDataProvider
PAYMENTINFO
placeholder"] + + style OrgProvider fill:#f5f5f5,stroke:#bbb,stroke-dasharray:5 + style PaymentProvider fill:#f5f5f5,stroke:#bbb,stroke-dasharray:5 +``` + +--- + +## Dispatch Flow + +The `ApplicantProfileAppService.GetApplicantProfileAsync` method is the central orchestrator. It: + +1. Creates a new `ApplicantProfileDto` and copies request fields (`ProfileId`, `Subject`, `TenantId`, `Key`). +2. Looks up the matching `IApplicantProfileDataProvider` by `Key` in an in-memory dictionary (case-insensitive). +3. Calls `provider.GetDataAsync(request)` if found; otherwise logs a warning. +4. Returns the DTO with the polymorphic `Data` property populated. + +```mermaid +sequenceDiagram + participant C as Client + participant Ctrl as ApplicantProfileController + participant Svc as ApplicantProfileAppService + participant Dict as Provider Dictionary + participant P as IApplicantProfileDataProvider + + C->>Ctrl: GET /api/app/applicant-profiles/profile?Key=X&... + Ctrl->>Svc: GetApplicantProfileAsync(request) + Svc->>Dict: TryGetValue(request.Key) + alt Key found + Dict-->>Svc: provider + Svc->>P: GetDataAsync(request) + P-->>Svc: ApplicantProfileDataDto (concrete subclass) + else Key not found + Svc->>Svc: Log warning + end + Svc-->>Ctrl: ApplicantProfileDto { Data = ... } + Ctrl-->>C: 200 OK (JSON) +``` + +--- + +## Provider Details + +### 1. ContactInfoDataProvider (`CONTACTINFO`) + +**Purpose:** Aggregates contact information from three sources — profile-linked contacts, application-level contacts, and applicant agent contacts derived from the submission login token. + +**Dependencies:** +- `ICurrentTenant` — for multi-tenant scoping +- `IApplicantProfileContactService` — encapsulates contact query logic + +**Logic:** + +1. Switches to the requested tenant context. +2. Retrieves **profile contacts** — contacts linked to the applicant profile via `ContactLink` records where `RelatedEntityType == "ApplicantProfile"` and `RelatedEntityId == profileId`. These are **editable** (`IsEditable = true`). +3. Retrieves **application contacts** — contacts on applications whose form submissions match the normalized OIDC subject. These are **read-only** (`IsEditable = false`). +4. Retrieves **applicant agent contacts** — contact information derived from `ApplicantAgent` records on applications whose form submissions match the normalized OIDC subject. The join path is `Submission → Application → ApplicantAgent`. These are **read-only** (`IsEditable = false`). +5. Merges all three lists into a single `ApplicantContactInfoDto.Contacts` collection. + +**Subject Normalization:** The OIDC subject (e.g. `user@idir`) is normalized by stripping everything after `@` and converting to uppercase. + +```mermaid +flowchart TD + Start([GetDataAsync called]) + Tenant["Switch to request.TenantId"] + + subgraph ProfileContacts["Profile Contacts - Editable"] + PC1["Query ContactLink
WHERE RelatedEntityType = 'ApplicantProfile'
AND RelatedEntityId = profileId
AND IsActive = true"] + PC2["JOIN Contact ON ContactId"] + PC3["Map to ContactInfoItemDto
IsEditable = true"] + PC1 --> PC2 --> PC3 + end + + subgraph AppContacts["Application Contacts - Read-Only"] + AC1["Normalize Subject
strip domain, uppercase"] + AC2["Query ApplicationFormSubmission
WHERE OidcSub = normalizedSubject"] + AC3["JOIN ApplicationContact
ON ApplicationId"] + AC3b["JOIN Application
ON ApplicationId
for ReferenceNo"] + AC4["Map to ContactInfoItemDto
IsEditable = false"] + AC1 --> AC2 --> AC3 --> AC3b --> AC4 + end + + subgraph AgentContacts["Applicant Agent Contacts - Read-Only"] + AG1["Normalize Subject
strip domain, uppercase"] + AG2["Query ApplicationFormSubmission
WHERE OidcSub = normalizedSubject"] + AG3["JOIN ApplicantAgent
ON ApplicationId"] + AG3b["JOIN Application
ON ApplicationId
for ReferenceNo"] + AG4["Map to ContactInfoItemDto
ContactType = 'ApplicantAgent'
IsEditable = false"] + AG1 --> AG2 --> AG3 --> AG3b --> AG4 + end + + Start --> Tenant + Tenant --> PC1 + Tenant --> AC1 + Tenant --> AG1 + PC3 --> Merge["Merge into Contacts list"] + AC4 --> Merge + AG4 --> Merge + Merge --> Return([Return ApplicantContactInfoDto]) +``` + +**Data Sources:** + +| Source | Entity | Join Path | Editable | +|--------|--------|-----------|----------| +| Profile Contacts | `ContactLink` → `Contact` | `ContactLink.RelatedEntityId = profileId` | ✅ Yes | +| Application Contacts | `ApplicationFormSubmission` → `ApplicationContact` → `Application` | `Submission.OidcSub = normalizedSubject`, `Application.Id` for `ReferenceNo` | ❌ No | +| Applicant Agent Contacts | `ApplicationFormSubmission` → `ApplicantAgent` → `Application` | `Submission.ApplicationId = Agent.ApplicationId`, `Application.Id` for `ReferenceNo` | ❌ No | + +**Applicant Agent Field Mapping:** + +The `ApplicantAgent` entity is populated from the CHEFS submission login token during intake import. Its fields are mapped to `ContactInfoItemDto` as follows: + +| ApplicantAgent Field | ContactInfoItemDto Field | +|---------------------|-------------------------| +| `Id` | `ContactId` | +| `Name` | `Name` | +| `Title` | `Title` | +| `Email` | `Email` | +| `Phone` | `WorkPhoneNumber` | +| `PhoneExtension` | `WorkPhoneExtension` | +| `Phone2` | `MobilePhoneNumber` | +| `RoleForApplicant` | `Role` | +| `ApplicationId` | `ApplicationId` | +| `Application.ReferenceNo` | `ReferenceNo` | +| _(literal)_ `"ApplicantAgent"` | `ContactType` | + +--- + +### 2. AddressInfoDataProvider (`ADDRESSINFO`) + +**Purpose:** Retrieves applicant addresses by querying address records linked to the applicant's form submissions. Addresses are resolved via two join paths and deduplicated. + +**Dependencies:** +- `ICurrentTenant` — for multi-tenant scoping +- `IRepository` — form submissions +- `IRepository` — address records +- `IRepository` — applications (for `ReferenceNo`) + +**Logic:** + +1. Normalizes the OIDC subject. +2. Switches to the requested tenant context. +3. Queries addresses through **two join paths**: + - **By ApplicationId:** `Submission → Address (on ApplicationId) → Application` — these are **not editable** (owned by an application). + - **By ApplicantId:** `Submission → Address (on ApplicantId) → Application (LEFT JOIN)` — these are **editable** (owned by the applicant directly). +4. Concatenates both result sets. +5. **Deduplicates** by `Address.Id` — if the same address appears in both sets, the application-linked (non-editable) version takes priority. +6. Maps `AddressType` enum values to human-readable names (`Physical`, `Mailing`, `Business`). +7. Checks the `isPrimary` extended property on addresses; if no address is marked primary, the most recently created address is auto-promoted. + +```mermaid +flowchart TD + Start([GetDataAsync called]) + Norm["Normalize Subject
strip domain, uppercase"] + Tenant["Switch to request.TenantId"] + + Start --> Norm --> Tenant + + subgraph ByAppId["Join Path: By ApplicationId - Read-Only"] + A1["ApplicationFormSubmission
WHERE OidcSub = normalized"] + A2["JOIN ApplicantAddress
ON Submission.ApplicationId = Address.ApplicationId"] + A3["JOIN Application
ON Address.ApplicationId = Application.Id"] + A4["IsEditable = false"] + A1 --> A2 --> A3 --> A4 + end + + subgraph ByApplicantId["Join Path: By ApplicantId - Editable"] + B1["ApplicationFormSubmission
WHERE OidcSub = normalized"] + B2["JOIN ApplicantAddress
ON Submission.ApplicantId = Address.ApplicantId"] + B3["LEFT JOIN Application
ON Address.ApplicationId = Application.Id"] + B4["IsEditable = true"] + B1 --> B2 --> B3 --> B4 + end + + Tenant --> A1 + Tenant --> B1 + + A4 --> Concat["CONCAT both result sets"] + B4 --> Concat + Concat --> Dedup["Deduplicate by Address.Id
prefer IsEditable = false"] + Dedup --> Map["Map to AddressInfoItemDto
AddressType to display name
Check isPrimary extended property"] + Map --> Primary{"Any address
marked primary?"} + Primary -->|Yes| Return([Return ApplicantAddressInfoDto]) + Primary -->|No| AutoPrimary["Mark most recent
address as primary"] + AutoPrimary --> Return +``` + +**Deduplication Rule:** When the same address ID appears in both join paths, the application-linked record (`IsEditable = false`) wins. This is achieved by grouping on `Address.Id` and ordering by `IsEditable` ascending (`false` < `true`). + +--- + +### 3. SubmissionInfoDataProvider (`SUBMISSIONINFO`) + +**Purpose:** Lists all form submissions associated with the applicant's OIDC subject, along with application metadata and a link to view the form in CHEFS. + +**Dependencies:** +- `ICurrentTenant` — for multi-tenant scoping +- `IRepository` — form submissions +- `IRepository` — applications +- `IRepository` — status records +- `IEndpointManagementAppService` — resolves the CHEFS API base URL +- `ILogger` — logging + +**Logic:** + +1. Normalizes the OIDC subject. +2. Resolves the **CHEFS form view URL** from the `INTAKE_API_BASE` dynamic URL setting: + - Fetches the base URL (e.g. `https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1`) + - Strips the trailing `/api/v1` segment + - Appends `/form/view?s=` to create the view link template + - Falls back to an empty string on failure. +3. Switches to the requested tenant context. +4. Queries `ApplicationFormSubmission` → `Application` → `ApplicationStatus` where `OidcSub` matches. +5. Maps each result to a `SubmissionInfoItemDto`: + - `ReceivedTime` = the submission's `CreationTime` in the system. + - `SubmissionTime` = the `createdAt` timestamp parsed from the CHEFS JSON payload; falls back to `CreationTime` if parsing fails. + - `Status` = the `ExternalStatus` from the application status record. + - `LinkId` = the `ChefsSubmissionGuid` used to build a direct link to the form. + +```mermaid +flowchart TD + Start([GetDataAsync called]) + Norm["Normalize Subject
strip domain, uppercase"] + + Start --> Norm + Norm --> ResolveUrl["ResolveFormViewUrlAsync"] + Norm --> Tenant["Switch to request.TenantId"] + + subgraph URLResolution["CHEFS Form View URL Resolution"] + U1["Fetch INTAKE_API_BASE
via IEndpointManagementAppService"] + U2["Strip trailing /api/v1"] + U3["Append /form/view?s="] + U4["Set as dto.LinkSource"] + U1 --> U2 --> U3 --> U4 + end + + ResolveUrl --> U1 + + subgraph Query["Submission Query"] + Q1["ApplicationFormSubmission
WHERE OidcSub = normalized"] + Q2["JOIN Application
ON Submission.ApplicationId = Application.Id"] + Q3["JOIN ApplicationStatus
ON Application.ApplicationStatusId = Status.Id"] + Q4["SELECT Id, ChefsSubmissionGuid,
CreationTime, Submission JSON,
ReferenceNo, ProjectName, ExternalStatus"] + Q1 --> Q2 --> Q3 --> Q4 + end + + Tenant --> Q1 + + Q4 --> MapItems["Map to SubmissionInfoItemDto
ReceivedTime = CreationTime
SubmissionTime = parse JSON createdAt
Status = ExternalStatus
LinkId = ChefsSubmissionGuid"] + + U4 --> Result + MapItems --> Result([Return ApplicantSubmissionInfoDto]) +``` + +**Submission Time Resolution:** + +```mermaid +flowchart LR + JSON["Submission JSON"] + Parse{"Parse JSON?"} + HasField{"Has 'createdAt'
field?"} + ValidDate{"Valid DateTime?"} + Use["Use parsed DateTime"] + Fallback["Use CreationTime
(fallback)"] + + JSON --> Parse + Parse -->|Success| HasField + Parse -->|JsonException| Fallback + HasField -->|Yes| ValidDate + HasField -->|No| Fallback + ValidDate -->|Yes| Use + ValidDate -->|No| Fallback +``` + +--- + +### 4. OrgInfoDataProvider (`ORGINFO`) — Placeholder + +**Purpose:** Will provide organization information for the applicant profile. + +**Current Status:** Returns an empty `ApplicantOrgInfoDto` with no data fields populated. No dependencies or query logic implemented yet. + +--- + +### 5. PaymentInfoDataProvider (`PAYMENTINFO`) — Placeholder + +**Purpose:** Will provide payment information for the applicant profile. + +**Current Status:** Returns an empty `ApplicantPaymentInfoDto` with no data fields populated. No dependencies or query logic implemented yet. + +--- + +## Common Patterns + +### Subject Normalization + +All providers that query by OIDC subject apply the same normalization: + +``` +Input: "5ay5pewjqddncvlzlukm3gn2r7vdzq6q@chefs-frontend-5299" → Output: "5AY5PEWJQDDNCVLZLUKM3GN2R7VDZQ6Q" +Input: "user@idir" → Output: "USER" +Input: "USER" → Output: "USER" +``` + +The portion after `@` is stripped and the remainder is uppercased. This matches the format stored in `ApplicationFormSubmission.OidcSub`, which is populated during intake import (see [OIDC Subject Ingestion from CHEFS](#oidc-subject-ingestion-from-chefs) below). + +### Multi-Tenancy + +Every provider switches to the requested `TenantId` using `ICurrentTenant.Change(request.TenantId)` before querying tenant-scoped data. This ensures queries hit the correct tenant database. + +### Polymorphic Serialization + +The `ApplicantProfileDataDto` base class uses `System.Text.Json` polymorphic attributes: + +``` +[JsonPolymorphic(TypeDiscriminatorPropertyName = "dataType")] +[JsonDerivedType(typeof(ApplicantContactInfoDto), "CONTACTINFO")] +[JsonDerivedType(typeof(ApplicantOrgInfoDto), "ORGINFO")] +[JsonDerivedType(typeof(ApplicantAddressInfoDto), "ADDRESSINFO")] +[JsonDerivedType(typeof(ApplicantSubmissionInfoDto), "SUBMISSIONINFO")] +[JsonDerivedType(typeof(ApplicantPaymentInfoDto), "PAYMENTINFO")] +``` + +The JSON response includes a `dataType` discriminator field so consumers can deserialize the correct concrete type. + +### Editability + +Providers distinguish between **editable** and **read-only** data: + +| Provider | Editable Source | Read-Only Source | +|----------|----------------|-----------------| +| ContactInfo | Profile-linked contacts | Application-level contacts, Applicant agent contacts | +| AddressInfo | Addresses linked via ApplicantId | Addresses linked via ApplicationId | + +--- + +## OIDC Subject Ingestion from CHEFS + +The `OidcSub` field stored on `ApplicationFormSubmission` is the key that links submissions to an applicant across the profile system. It is populated **at intake import time** by `IntakeFormSubmissionManager.ProcessFormSubmissionAsync`, which calls `IntakeSubmissionHelper.ExtractOidcSub`. + +### CHEFS Form Prerequisite + +For the OIDC subject to be available, the CHEFS form **must** include a **hidden form control** whose value is set to the authenticated user's JWT token. When the form is submitted, CHEFS includes this token payload in the submission JSON, making the `sub` claim accessible to the import process. + +If this hidden control is not configured, the `sub` field will be absent and `ExtractOidcSub` will fall back to `Guid.Empty`. + +### Token Structure in CHEFS Submission JSON + +When set up correctly, the submission JSON received from CHEFS contains the decoded token as a nested object. Example: + +```json +{ + "submission": { + "data": { + "applicantAgent": { + "aud": "chefs-frontend-5299", + "azp": "chefs-frontend-5299", + "exp": 1770327585, + "iat": 1770327285, + "iss": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard", + "jti": "onrtac:b2571d2d-ebbf-4f50-aaf8-5d603aa6a171", + "sub": "5ay5pewjqddncvlzlukm3gn2r7vdzq6q@chefs-frontend-5299", + "typ": "Bearer", + "scope": "openid chefs-frontend-5299 idir bceidbusiness email profile bceidbasic", + "family_name": "SURFACE", + "given_names": "PRISCILA", + "identity_provider": "chefs-frontend-5299", + "preferred_username": "5ay5pewjqddncvlzlukm3gn2r7vdzq6q@chefs-frontend-5299" + } + } + } +} +``` + +### Extraction Logic (`IntakeSubmissionHelper.ExtractOidcSub`) + +The helper searches the dynamic submission object through **multiple configured paths** in priority order until a non-empty value is found: + +| Priority | Search Path | Description | +|----------|------------|-------------| +| 1 | `submission→data→applicantAgent→sub` | Primary path — standard hidden control name | +| 2 | `submission→data→hiddenApplicantAgent→sub` | Alternate hidden control name | +| 3 | `createdBy` | Top-level CHEFS fallback field | + +Once the raw `sub` value is found (e.g. `5ay5pewjqddncvlzlukm3gn2r7vdzq6q@chefs-frontend-5299`), it is normalized: +- Everything after `@` is stripped → `5ay5pewjqddncvlzlukm3gn2r7vdzq6q` +- Converted to uppercase → `5AY5PEWJQDDNCVLZLUKM3GN2R7VDZQ6Q` +- If no value is found, returns `Guid.Empty` as a string + +```mermaid +flowchart TD + Start([CHEFS Submission Received]) + Import["IntakeFormSubmissionManager
ProcessFormSubmissionAsync"] + Extract["IntakeSubmissionHelper.ExtractOidcSub"] + P1{"Try: submission / data /
applicantAgent / sub"} + P2{"Try: submission / data /
hiddenApplicantAgent / sub"} + P3{"Try: createdBy"} + Strip["Strip domain suffix"] + Upper["Convert to uppercase"] + Empty["Use Guid.Empty"] + Store["Store as ApplicationFormSubmission.OidcSub"] + Used(["Used by all providers to
match submissions to the applicant"]) + + Start --> Import --> Extract + Extract --> P1 + P1 -->|found| Strip + P1 -->|empty| P2 + P2 -->|found| Strip + P2 -->|empty| P3 + P3 -->|found| Strip + P3 -->|empty| Empty + Strip --> Upper --> Store + Empty --> Store + Store --> Used +``` + +### Import Call Site + +In `IntakeFormSubmissionManager.ProcessFormSubmissionAsync`: + +```csharp +var newSubmission = new ApplicationFormSubmission +{ + OidcSub = IntakeSubmissionHelper.ExtractOidcSub(formSubmission.submission), + ApplicantId = application.ApplicantId, + ApplicationFormId = applicationForm.Id, + ChefsSubmissionGuid = intakeMap.SubmissionId ?? $"{Guid.Empty}", + ApplicationId = application.Id, + Submission = dataNode?.ToString() ?? string.Empty +}; +``` + +The `formSubmission.submission` object passed to `ExtractOidcSub` is the `submission` node from the CHEFS JSON payload. The helper traverses into `data→applicantAgent→sub` to reach the token's `sub` claim. + +--- + +## Full Request Lifecycle + +```mermaid +sequenceDiagram + participant Client + participant Controller as ApplicantProfileController + participant AuthFilter as ApiKeyAuthorizationFilter + participant AppService as ApplicantProfileAppService + participant Provider as IApplicantProfileDataProvider + participant TenantCtx as ICurrentTenant + participant DB as Tenant Database + + Client->>Controller: GET /api/app/applicant-profiles/profile
?ProfileId=...&Subject=...&TenantId=...&Key=CONTACTINFO + Controller->>AuthFilter: Validate API Key + AuthFilter-->>Controller: ✅ Authorized + Controller->>AppService: GetApplicantProfileAsync(request) + + Note over AppService: Build ApplicantProfileDto shell
with ProfileId, Subject, TenantId, Key + + AppService->>AppService: _providersByKey.TryGetValue("CONTACTINFO") + AppService->>Provider: GetDataAsync(request) + + Provider->>TenantCtx: Change(request.TenantId) + TenantCtx-->>Provider: Scoped to tenant + + Provider->>DB: Query contacts / addresses / submissions + DB-->>Provider: Raw data + + Provider->>Provider: Normalize, deduplicate, map to DTOs + Provider-->>AppService: ApplicantContactInfoDto + + Note over AppService: dto.Data = contactInfoDto + + AppService-->>Controller: ApplicantProfileDto + Controller-->>Client: 200 OK
{ profileId, subject, tenantId, key,
data: { dataType: "CONTACTINFO", contacts: [...] } } +``` + +--- + +## Project Structure + +``` +src/ +├── Unity.GrantManager.Application.Contracts/ApplicantProfile/ +│ ├── ApplicantProfileDto.cs # Response wrapper DTO +│ ├── ApplicantProfileRequest.cs # Request models (base + info) +│ ├── IApplicantProfileAppService.cs # App service interface +│ ├── IApplicantProfileContactService.cs # Contact service interface +│ ├── IApplicantProfileDataProvider.cs # Provider strategy interface +│ └── ProfileData/ +│ ├── ApplicantProfileDataDto.cs # Polymorphic base (discriminator) +│ ├── ApplicantContactInfoDto.cs # CONTACTINFO response +│ ├── ApplicantOrgInfoDto.cs # ORGINFO response (placeholder) +│ ├── ApplicantAddressInfoDto.cs # ADDRESSINFO response +│ ├── ApplicantSubmissionInfoDto.cs # SUBMISSIONINFO response +│ ├── ApplicantPaymentInfoDto.cs # PAYMENTINFO response (placeholder) +│ ├── ContactInfoItemDto.cs # Individual contact item +│ ├── AddressInfoItemDto.cs # Individual address item +│ └── SubmissionInfoItemDto.cs # Individual submission item +│ +├── Unity.GrantManager.Application/ApplicantProfile/ +│ ├── ApplicantProfileAppService.cs # Central orchestrator +│ ├── ApplicantProfileContactService.cs # Contact query logic +│ ├── ApplicantProfileKeys.cs # Key constants +│ ├── AddressInfoDataProvider.cs # ADDRESSINFO provider +│ ├── ContactInfoDataProvider.cs # CONTACTINFO provider +│ ├── SubmissionInfoDataProvider.cs # SUBMISSIONINFO provider +│ ├── OrgInfoDataProvider.cs # ORGINFO provider (placeholder) +│ └── PaymentInfoDataProvider.cs # PAYMENTINFO provider (placeholder) +│ +├── Unity.GrantManager.Application/Intakes/ +│ ├── IntakeFormSubmissionManager.cs # Import orchestrator (calls ExtractOidcSub) +│ └── IntakeSubmissionHelper.cs # OidcSub extraction from CHEFS token +│ +└── Unity.GrantManager.HttpApi/Controllers/ + └── ApplicantProfileController.cs # API controller entry point +``` diff --git a/applications/Unity.GrantManager/docs/reporting/get_worksheet_data_specification.md b/documentation/reporting/get_worksheet_data_specification.md similarity index 100% rename from applications/Unity.GrantManager/docs/reporting/get_worksheet_data_specification.md rename to documentation/reporting/get_worksheet_data_specification.md diff --git a/openshift/Readme.md b/openshift/Readme.md deleted file mode 100644 index eb9eb63c2..000000000 --- a/openshift/Readme.md +++ /dev/null @@ -1,95 +0,0 @@ -# Instructions to Install Unity Project - -## Step 1: Create templates from code - -You can create the required templates using the web OpenShift console or the oc CLI. -``` -# Delete build templates -oc delete templates --all - -# Create build templates -oc create -f $repository\database\unity-backup-cronjob.yaml -oc create -f $repository\database\unity-database.yaml -oc create -f $repository\openshift\unity-imagestream.yaml -oc create -f $repository\openshift\unity-grantmanager-dbmigrator-job.yaml -oc create -f $repository\openshift\unity-grantmanager-web.yaml -oc create -f $repository\openshift\unity-networkpolicy.yaml -oc create -f $repository\openshift\unity-rabbitmq.yaml -oc create -f $repository\openshift\unity-s3-object-storage.yaml -oc create -f $repository\openshift\unity-app-data-web.json -oc create -f $repository\openshift\unity-chefs-data-web.json -oc create -f $repository\openshift\unity-metabase.yaml -``` - -## Step 2: Create .env paramater files - -As a best practice, store copies of these files in a secure location. -``` -"database.env" -"dbmigrator-job.env" -"grantmanager-web.env" -"S3-storage.env" -"metabase.env" -"rabbitmq.env" -``` - -Use oc get templates to find all available parameters of a project template. -oc get templates - -| **NAME** | **DESCRIPTION** | -|-----------|-----------------| -| unity-app-data-web | An example Nginx HTTP server and a reverse proxy (nginx) application that serves web content. | -| unity-backup-cronjob | Template for running a recurring backup script in OpenShift. | -| unity-chefs-data-web | An example Nginx HTTP server and a reverse proxy (nginx) application that serves web content. | -| unity-database | PostgreSQL database service with persistent storage. | -| unity-grantmanager-dbmigrator-job | Template for running a dotnet console application once in OpenShift. | -| unity-grantmanager-pgbackup-job | Template for running a dotnet console application once in OpenShift. | -| unity-grantmanager-web | Template for running a DotNet web application on OpenShift. | -| unity-imagestream | Template for tracking of changes in the application image. | -| unity-metabase | Template for running a DotNet web application on OpenShift. | -| unity-networkpolicy | Template for communications rules in OpenShift. | -| unity-rabbitmq | Template for running RabbitMQ message queue application on OpenShift. | -| unity-s3-object-storage | Template for S3 connection information in OpenShift. | - -## Step 3: Create or replace project resources - -You can create OpenShift resources using the web OpenShift console or the oc CLI. - -Using the command line, -``` -# Replace the running network and namespace policy -oc delete networkpolicies --all -oc process unity-networkpolicy | oc create -f - -oc policy add-role-to-user system:image-puller system:serviceaccount:${project}:default --namespace=${tools} -oc policy add-role-to-group system:image-puller system:serviceaccounts:${project} --namespace=${tools} - -# Create Database objects from templates with parameters -oc process unity-database --param-file=${params}-database.env | oc create -f - -helm upgrade --install ${release}-hippo-ha . -f $repository\database\crunchy-postgres\values.yaml -f ${params}-pgo-custom-values.yaml -oc process unity-backup-cronjob --param-file=${params}-database.env | oc create -f - - -# Create DbMigraitor objects from templates with parameters -oc process unity-grantmanager-imagestream -p APPLICATION_GROUP=${release}-unity-grantmanager -p APPLICATION_NAME=${release}-unity-dbmigrator | oc create -f - -oc import-image ${release}-unity-dbmigrator:$tag --confirm --from=image-registry.openshift-image-registry.svc:5000/${tools}/${release}-unity-dbmigrator-build:$tag -oc process unity-grantmanager-dbmigrator-job --param-file=${params}-dbmigrator-job.env | oc create -f - -oc wait jobs/${release}-unity-dbmigrator --for condition=complete --timeout=120s - -# Create S3 storage objects from templates with parameters -oc process unity-s3-object-storage --param-file=${params}-S3.env | oc create -f - - -# Create GrantManager objects from templates with parameters -oc process unity-grantmanager-imagestream -p APPLICATION_GROUP=${release}-unity-grantmanager -p APPLICATION_NAME=${release}-unity-grantmanager | oc create -f - -oc import-image ${release}-unity-grantmanager:$tag --confirm --from=image-registry.openshift-image-registry.svc:5000/${tools}/${release}-unity-grantmanager-build:$tag -oc process unity-grantmanager-web --param-file=${params}-grantmanager-web.env | oc create -f - -oc wait dc/${release}-unity-grantmanager-web --for condition=available=true --timeout=120s - -# Create RabbitMQ objects from templates with parameters -oc process unity-rabbitmq --param-file=${project}-rabbitmq.env | oc create -f - -oc wait dc/${namespace}unity-rabbitmq --for condition=available - -# Deployment for app-data-web -oc process unity-app-data-web -p IMAGEPULL_NAMESPACE=${tools} -p IMAGESTREAM_NAME=${namespace}-unity-app-data-build -p IMAGESTREAM_TAG=latest | oc create -f - - -# Deployment for reporting -oc process unity-metabase --param-file=${project}-metabase.env | oc create -f - -``` \ No newline at end of file diff --git a/openshift/SSL_CERTIFICATE.md b/openshift/SSL_CERTIFICATE.md deleted file mode 100644 index 10e8e9218..000000000 --- a/openshift/SSL_CERTIFICATE.md +++ /dev/null @@ -1,94 +0,0 @@ -# Instructions to Install Unity SSL Certificate - -## Step 1: Submit a CSR - -A Certificate Signing Request (CSR) is necessary for new certificates or certificate renewals. Contact the ISB Operations team they will make an iStore request with associated approved funding and generate the required .csr file, then provide the SSL certificate files when they are ready. - - -## Step 2: Install SSL certificates - -As a best practice, store copies of these files in the ISB Operations SSL certificate store (e.g. Zone-B server filesystem). That way, the keys can be retrieved when needed. Only project namespace administrators can edit OpenShift certificate objects. - -Ensure you have all four (4) required files: - -- Certificate: unity.gov.bc.ca.txt -- Private Key: unity.gov.bc.ca.key -- CA Certificate: L1KChain.txt -- CA Root Certificate: G2Root.txt - -## Step 3: Create route for unity.gov.bc.ca - -You can create network routes using the web OpenShift console or the oc CLI. - -Using the command line, the following example creates a secured HTTPS route named `unity-gov-bc-ca` that directs traffic to the `unity-grantmanager-web` service: - -```bash -oc create route edge unity-gov-bc-ca \ - --service=unity-grantmanager-web \ - --cert=unity.gov.bc.ca.txt \ - --key=unity.gov.bc.ca.key \ - --ca-cert=L1KChain.txt \ - --hostname=unity.gov.bc.ca \ - --insecure-policy=Redirect -``` - -Using the web console, you can navigate to the **Administrator > Networking > Routes** section of the conaole. - -Click **Create Route** to define and create a route in the project. - -Use the following settings: - -- Name: unity-gov-bc-ca -- Hostname: unity.gov.bc.ca -- Path: `/` -- Service: unity-grantmanager-web -- Secure Route: (yes) -- TLS Termination: Edge -- Insecure Traffic: Redirect - -| Route field | Source file | -| -------------------------- | ------------------- | -| Certificate | unity.gov.bc.ca.txt | -| Private Key | unity.gov.bc.ca.key | -| CA Certificate | L1KChain.txt | - -## Step 4: Verify new route - -The site should work immediately after saving these route settings. - -- Check that https://unity.gov.bc.ca is live and that the application landing page loads correctly. -- Verify SSO (Keycloak) settings - https://bcgov.github.io/sso-requests - -## Optional steps to generate a local CSR -Run the openssl utility with the CSR and private key options **these do not need to be created on the intended machine or containers**. - -```bashs -openssl req -new -newkey rsa:2048 -nodes -out unity.gov.bc.ca.csr \ - -keyout unity.gov.bc.ca.key \ - -subj "/C=CA/ST=British Columbia/L=Victoria/O=Government of the Province of British Columbia/OU=CITZ/CN=unity.gov.bc.ca" -``` - -Response should be: - -``` -Generating a RSA private key -.........+++++ -...............................+++++ -writing new private key to 'unity.gov.bc.ca.key' ------ -You are about to be asked to enter information that will be incorporated -into your certificate request. -What you are about to enter is what is called a Distinguished Name or a DN. -There are quite a few fields but you can leave some blank -For some fields there will be a default value, -If you enter '.', the field will be left blank. ------ -Country Name (2 letter code) [AU]:CA -State or Province Name (full name) [Some-State]:British Columbia -Locality Name (eg, city) []:Victoria -Organization Name (eg, company) [Internet Widgits Pty Ltd]:Government of the Province of British Columbia -Organizational Unit Name (eg, section) []:JEDI -Common Name (e.g. server FQDN or YOUR name) []:unity.gov.bc.ca -Email Address []: - -Keep the secret key and send the `.csr` file to the OCIO Access and Directory Management Services team they will require an iStore order to process the `.csr` file and will provide the SSL certificates when they are ready. \ No newline at end of file diff --git a/openshift/redis-sentinel/.helmignore b/openshift/redis-sentinel/.helmignore deleted file mode 100644 index 0e8a0eb36..000000000 --- a/openshift/redis-sentinel/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/openshift/redis-sentinel/Chart.lock b/openshift/redis-sentinel/Chart.lock deleted file mode 100644 index 55e55af74..000000000 --- a/openshift/redis-sentinel/Chart.lock +++ /dev/null @@ -1,6 +0,0 @@ -dependencies: -- name: redis - repository: https://charts.bitnami.com/bitnami - version: 21.1.11 -digest: sha256:98f3d6fdc3360f0ea929a647528658e8693cf22ea503a099dc77af35e46af99f -generated: "2025-06-03T11:36:36.363955-07:00" diff --git a/openshift/redis-sentinel/Chart.yaml b/openshift/redis-sentinel/Chart.yaml deleted file mode 100644 index dc01835b7..000000000 --- a/openshift/redis-sentinel/Chart.yaml +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: v2 -name: redis -description: High Availability Redis Chart - -# A chart can be either an 'application' or a 'library' chart. -# -# Application charts are a collection of templates that can be packaged into versioned archives -# to be deployed. -# -# Library charts provide useful utilities or functions for the chart developer. They're included as -# a dependency of application charts to inject those utilities and functions into the rendering -# pipeline. Library charts do not define any templates and therefore cannot be deployed. -type: application - -# This is the chart version. This version number should be incremented each time you make changes -# to the chart and its templates, including the app version. -# Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.2 - -# This is the version number of the application being deployed. This version number should be -# incremented each time you make changes to the application. Versions are not expected to -# follow Semantic Versioning. They should reflect the version the application is using. -# It is recommended to use it with quotes. -appVersion: "8.2.1" - -dependencies: - - name: redis - version: "21.1.11" # Specify the version you want - repository: "https://charts.bitnami.com/bitnami" diff --git a/openshift/redis-sentinel/charts/redis-21.1.11.tgz b/openshift/redis-sentinel/charts/redis-21.1.11.tgz deleted file mode 100644 index 78e1f139e..000000000 Binary files a/openshift/redis-sentinel/charts/redis-21.1.11.tgz and /dev/null differ diff --git a/openshift/redis-sentinel/values-dev.yaml b/openshift/redis-sentinel/values-dev.yaml deleted file mode 100644 index 7cfd5d67e..000000000 --- a/openshift/redis-sentinel/values-dev.yaml +++ /dev/null @@ -1,17 +0,0 @@ -redis: - fullnameOverride: dev-redis-ha - sentinel: - masterSet: redisMasterSet - resources: - requests: - memory: "64Mi" - cpu: "20m" - replica: - replicaCount: 3 - persistence: - enabled: true - size: 96Mi - resources: - requests: - memory: "64Mi" - cpu: "20m" diff --git a/openshift/redis-sentinel/values-dev2.yaml b/openshift/redis-sentinel/values-dev2.yaml deleted file mode 100644 index 538cc82aa..000000000 --- a/openshift/redis-sentinel/values-dev2.yaml +++ /dev/null @@ -1,17 +0,0 @@ -redis: - fullnameOverride: dev2-redis-ha - sentinel: - masterSet: redisMasterSet - resources: - requests: - memory: "64Mi" - cpu: "20m" - replica: - replicaCount: 3 - persistence: - enabled: true - size: 96Mi - resources: - requests: - memory: "64Mi" - cpu: "20m" diff --git a/openshift/redis-sentinel/values-prod.yaml b/openshift/redis-sentinel/values-prod.yaml deleted file mode 100644 index a42191367..000000000 --- a/openshift/redis-sentinel/values-prod.yaml +++ /dev/null @@ -1,17 +0,0 @@ -redis: - fullnameOverride: prod-redis-ha - sentinel: - masterSet: redisMasterSet - resources: - requests: - memory: "64Mi" - cpu: "20m" - replica: - replicaCount: 3 - persistence: - enabled: true - size: 128Mi - resources: - requests: - memory: "64Mi" - cpu: "20m" diff --git a/openshift/redis-sentinel/values-test.yaml b/openshift/redis-sentinel/values-test.yaml deleted file mode 100644 index b4e5a015c..000000000 --- a/openshift/redis-sentinel/values-test.yaml +++ /dev/null @@ -1,17 +0,0 @@ -redis: - fullnameOverride: test-redis-ha - sentinel: - masterSet: redisMasterSet - resources: - requests: - memory: "64Mi" - cpu: "20m" - replica: - replicaCount: 3 - persistence: - enabled: true - size: 96Mi - resources: - requests: - memory: "64Mi" - cpu: "20m" diff --git a/openshift/redis-sentinel/values-uat.yaml b/openshift/redis-sentinel/values-uat.yaml deleted file mode 100644 index 6656d6bdf..000000000 --- a/openshift/redis-sentinel/values-uat.yaml +++ /dev/null @@ -1,17 +0,0 @@ -redis: - fullnameOverride: uat-redis-ha - sentinel: - masterSet: redisMasterSet - resources: - requests: - memory: "64Mi" - cpu: "20m" - replica: - replicaCount: 3 - persistence: - enabled: true - size: 96Mi - resources: - requests: - memory: "64Mi" - cpu: "20m" diff --git a/openshift/redis-sentinel/values.yaml b/openshift/redis-sentinel/values.yaml deleted file mode 100644 index d3bde39c7..000000000 --- a/openshift/redis-sentinel/values.yaml +++ /dev/null @@ -1,49 +0,0 @@ -global: - security: - allowInsecureImages: true - -redis: - # Bitnami chart version: updated from previous to 21.1.11 - # Redis image tag: updated from 7.2.4-debian-12-r9 to 7.2.5 (see Chart.yaml) - fullnameOverride: redis-ha - architecture: replication - auth: - enabled: true # If true, the Redis password will be enabled - sentinel: true # If true, the Redis Sentinel will be enabled - usePasswordFiles: false # If true, the password will be stored in a file - image: - registry: docker.io - repository: bitnamilegacy/redis - tag: 8.2.1 # updated from 7.2.4-debian-12-r9, 7.2.5, now using default Bitnami Redis 8.2.1 image - sentinel: - enabled: true - masterSet: "redisMasterSet" - image: - registry: docker.io - repository: bitnamilegacy/redis-sentinel - tag: 8.2.1 # Match the Redis version or use appropriate Sentinel version - persistence: - enabled: false - containerPorts: - sentinel: 26379 - podSecurityContext: - enabled: false - containerSecurityContext: - enabled: false - resources: - requests: - memory: "64Mi" - cpu: "20m" - replica: - replicaCount: 1 - podSecurityContext: - enabled: false - containerSecurityContext: - enabled: false - persistence: - enabled: true - size: 64Mi - resources: - requests: - memory: "64Mi" - cpu: "20m" diff --git a/openshift/tools-networkpolicy.yaml b/openshift/tools-networkpolicy.yaml deleted file mode 100644 index f479a2c18..000000000 --- a/openshift/tools-networkpolicy.yaml +++ /dev/null @@ -1,46 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -metadata: - name: tools-networkpolicy - # namespace: ${PROJECT_NAMESPACE}-${ENV_NAME} - # This template uses a separate parameter .env file to override the default values defined in this section. - labels: - template: tools-networkpolicy - annotations: - description: |- - Template for tools namespace communications in OpenShift. -parameters: -# Project namespace parameters - - name: PROJECT_NAMESPACE - displayName: "Project Namespace" - description: "The OpenShift project license plate 6 character alpha numeric." - required: true - generate: expression - from: "[a-z0-9]{6}" - - name: ENV_NAME - displayName: "Environment name" - description: "The OpenShift environment name [tools, dev, test, prod]." - required: true - value: "tools" -objects: - - kind: NetworkPolicy - apiVersion: networking.k8s.io/v1 - metadata: - name: intra-namespace-comms - spec: - - kind: NetworkPolicy - apiVersion: networking.k8s.io/v1 - metadata: - name: intra-namespace-comms - spec: - # Allow all pods within the project namespace to communicate - # to current tools namespace - podSelector: {} - ingress: - - from: - - namespaceSelector: - matchLabels: - environment: ${ENV_NAME} - name: ${PROJECT_NAMESPACE} - policyTypes: - - Ingress \ No newline at end of file diff --git a/openshift/unity-app-data-build.json b/openshift/unity-app-data-build.json deleted file mode 100644 index 107c8f7f2..000000000 --- a/openshift/unity-app-data-build.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "kind": "Template", - "apiVersion": "template.openshift.io/v1", - "metadata": { - "name": "unity-app-data-build", - "annotations": { - "openshift.io/display-name": "Nginx HTTP server and a reverse proxy", - "description": "An example Nginx HTTP server and a reverse proxy (nginx) application that serves static content.", - "tags": "${APPLICATION_NAME}", - "iconClass": "icon-nginx", - "openshift.io/long-description": "This template defines resources needed to develop a static application served by Nginx HTTP server and a reverse proxy (nginx), including a build configuration and application deployment configuration." - } - }, - "message": "The following service(s) have been created in your project: ${APPLICATION_NAME}.", - "labels": { - "template": "${APPLICATION_NAME}", - "app": "${APPLICATION_NAME}" - }, - "objects": [ - { - "kind": "BuildConfig", - "apiVersion": "build.openshift.io/v1", - "metadata": { - "name": "${APPLICATION_NAME}", - "labels": { - "app.kubernetes.io/part-of": "${APPLICATION_GROUP}" - }, - "annotations": { - "description": "Defines how to build the application", - "template.alpha.openshift.io/wait-for-ready": "true" - } - }, - "spec": { - "source": { - "type": "Git", - "git": { - "uri": "${SOURCE_REPOSITORY_URL}", - "ref": "${SOURCE_REPOSITORY_REF}" - }, - "contextDir": "${CONTEXT_DIR}" - }, - "strategy": { - "type": "Source", - "sourceStrategy": { - "from": { - "kind": "ImageStreamTag", - "namespace": "${NAMESPACE}", - "name": "nginx:${NGINX_VERSION}" - } - } - }, - "output": { - "to": { - "kind": "ImageStreamTag", - "name": "${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG}" - } - }, - "triggers": [ - { - "type": "ImageChange" - }, - { - "type": "ConfigChange" - } - ] - } - } - ], - "parameters": [ - { - "description": "The name of the application grouping.", - "displayName": "Application Group", - "name": "APPLICATION_GROUP", - "value": "unity-tools" - }, - { - "description": "The name of the application.", - "displayName": "Application Name", - "name": "APPLICATION_NAME", - "required": true, - "value": "unity-app-data-build" - }, - { - "name": "NAMESPACE", - "displayName": "Namespace", - "description": "The OpenShift Namespace where the ImageStream resides.", - "required": true, - "value": "openshift" - }, - { - "name": "NGINX_VERSION", - "displayName": "NGINX Version", - "description": "Version of NGINX image to be used (1.20-ubi8 by default).", - "required": true, - "value": "1.20-ubi9" - }, - { - "name": "SOURCE_REPOSITORY_URL", - "displayName": "Git Repository URL", - "description": "The URL of the repository with your application source code.", - "required": true, - "value": "https://github.com/bcgov/Unity.git" - }, - { - "name": "SOURCE_REPOSITORY_REF", - "displayName": "Git Reference", - "description": "Set this to a branch name, tag or other ref of your repository if you are not using the default branch.", - "value":"dev" - }, - { - "name": "CONTEXT_DIR", - "displayName": "Context Directory", - "description": "Set this to the relative path to your project if it is not in the root of your repository.", - "value": "/applications/Unity.Tools" - }, - { - "description": "The ImageStream Name", - "displayName": "Registry imagestream name", - "name": "IMAGESTREAM_NAME", - "value": "unity-app-data-build" - }, - { - "description": "The version of the image to use, e.g. v1.0.0, v0.1.0, latest the ImageStream tag.", - "displayName": "Application Version", - "name": "IMAGESTREAM_TAG", - "required": true, - "value": "latest" - } - ] -} diff --git a/openshift/unity-app-data-web.json b/openshift/unity-app-data-web.json deleted file mode 100644 index 4401350a3..000000000 --- a/openshift/unity-app-data-web.json +++ /dev/null @@ -1,241 +0,0 @@ -{ - "kind": "Template", - "apiVersion": "template.openshift.io/v1", - "metadata": { - "name": "unity-app-data-web", - "annotations": { - "openshift.io/display-name": "Nginx HTTP server and a reverse proxy", - "description": "An example Nginx HTTP server and a reverse proxy (nginx) application that serves static content.", - "tags": "${APPLICATION_NAME}", - "iconClass": "icon-nginx", - "openshift.io/long-description": "This template defines resources needed to develop a static application served by Nginx HTTP server and a reverse proxy (nginx), including a build configuration and application deployment configuration." - } - }, - "message": "The following service(s) have been created in your project: ${APPLICATION_NAME}.", - "labels": { - "template": "${APPLICATION_NAME}", - "app": "${APPLICATION_NAME}" - }, - "objects": [ - { - "kind": "Service", - "apiVersion": "v1", - "metadata": { - "name": "${APPLICATION_NAME}", - "labels": { - "app.kubernetes.io/part-of": "${APPLICATION_GROUP}" - }, - "annotations": { - "description": "Exposes and load balances the application pods" - } - }, - "spec": { - "ports": [ - { - "name": "80-tcp", - "protocol": "TCP", - "port": 80, - "targetPort": 8080 - } - ], - "selector": { - "name": "${APPLICATION_NAME}" - } - } - }, - { - "kind": "Route", - "apiVersion": "route.openshift.io/v1", - "metadata": { - "name": "${APPLICATION_NAME}", - "labels": { - "app.kubernetes.io/part-of": "${APPLICATION_GROUP}" - }, - "annotations": { - "haproxy.router.openshift.io/hsts_header": "max-age=31536000;includeSubDomains;preload", - "template.openshift.io/expose-uri": "http://{.spec.host}{.spec.path}" - } - }, - "spec": { - "host": "${APPLICATION_DOMAIN}", - "to": { - "kind": "Service", - "name": "${APPLICATION_NAME}" - }, - "httpHeaders": { - "actions": { - "request": null, - "response": [ - { - "action": { - "set": { "value": "SAMEORIGIN" }, - "type": "Set" - }, - "name": "X-Frame-Options" - }, - { - "action": { - "set": { "value": "nosniff" }, - "type": "Set" - }, - "name": "X-Content-Type-Options" - }, - { - "action": { - "set": { "value": "strict-origin-when-cross-origin" }, - "type": "Set" - }, - "name": "Referrer-Policy" - }, - { - "action": { - "set": { "value": "object-src 'none'; frame-ancestors 'none'" }, - "type": "Set" - }, - "name": "Content-Security-Policy" - } - ] - } - }, - "tls": { - "termination": "edge", - "insecureEdgeTerminationPolicy": "Redirect" - } - } - }, - { - "kind": "Deployment", - "apiVersion": "apps/v1", - "metadata": { - "name": "${APPLICATION_NAME}", - "labels": { - "app.openshift.io/runtime": "nginx", - "app.kubernetes.io/part-of": "${APPLICATION_GROUP}" - }, - "annotations": { - "description": "Defines how to deploy the application server", - "template.alpha.openshift.io/wait-for-ready": "true", - "image.openshift.io/triggers": "[{\"from\":{\"kind\":\"ImageStreamTag\",\"name\":\"${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG}\",\"namespace\":\"${IMAGEPULL_NAMESPACE}\"},\"fieldPath\":\"spec.template.spec.containers[?(@.name==\\\"${APPLICATION_NAME}\\\")].image\",\"pause\":\"true\"}]" - } - }, - "spec": { - "strategy": { - "type": "Recreate" - }, - "replicas": 1, - "selector": { - "matchLabels": { - "name": "${APPLICATION_NAME}", - "app": "${APPLICATION_NAME}" - } - }, - "template": { - "metadata": { - "labels": { - "name": "${APPLICATION_NAME}", - "app": "${APPLICATION_NAME}" - } - }, - "spec": { - "containers": [ - { - "name": "${APPLICATION_NAME}", - "image": "${IMAGEPULL_REGISTRY}/${IMAGEPULL_NAMESPACE}/${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG}", - "ports": [ - { - "containerPort": 8080 - } - ], - "readinessProbe": { - "timeoutSeconds": 3, - "initialDelaySeconds": 3, - "httpGet": { - "path": "/", - "port": 8080 - } - }, - "livenessProbe": { - "timeoutSeconds": 3, - "initialDelaySeconds": 30, - "httpGet": { - "path": "/", - "port": 8080 - } - }, - "env": [ - ], - "resources": { - "requests": { - "cpu": "${CPU_REQUEST}", - "memory": "${MEMORY_REQUEST}" - } - } - } - ] - } - } - } - } - ], - "parameters": [ - { - "description": "The name of the application grouping.", - "displayName": "Application Group", - "name": "APPLICATION_GROUP", - "value": "unity-tools" - }, - { - "description": "The name of the application.", - "displayName": "Application Name", - "name": "APPLICATION_NAME", - "required": true, - "value": "unity-app-data-web" - }, - { - "name": "APPLICATION_DOMAIN", - "displayName": "Application Hostname", - "description": "The exposed hostname that will route to the nginx service, if left blank a value will be defaulted.", - "value": "" - }, - { - "description": "The Namespace where the container image resides", - "displayName": "Registry Namespace", - "from": "[a-zA-Z0-9]{5}-tools", - "generate": "expression", - "name": "IMAGEPULL_NAMESPACE" - }, - { - "description": "The ImageStream Name", - "displayName": "Registry imagestream name", - "name": "IMAGESTREAM_NAME", - "value": "unity-data-web" - }, - { - "description": "The version of the image to use, e.g. v1.0.0, v0.1.0, latest the ImageStream tag.", - "displayName": "Application Version", - "name": "IMAGESTREAM_TAG", - "required": true, - "value": "latest" - }, - { - "description": "The registry path of the container image used.", - "displayName": "Registry location to pull from", - "name": "IMAGEPULL_REGISTRY", - "value": "image-registry.openshift-image-registry.svc:5000" - }, - { - "description": "The minimum amount of CPU the Container is guaranteed.", - "displayName": "CPU Request", - "name": "CPU_REQUEST", - "required": true, - "value": "50m" - }, - { - "description": "The minimum amount of Memory the Container is guaranteed.", - "displayName": "Memory Request", - "name": "MEMORY_REQUEST", - "required": true, - "value": "64Mi" - } - ] -} diff --git a/openshift/unity-applicantportal-build.yaml b/openshift/unity-applicantportal-build.yaml deleted file mode 100644 index 34b049a61..000000000 Binary files a/openshift/unity-applicantportal-build.yaml and /dev/null differ diff --git a/openshift/unity-applicantportal-web.yaml b/openshift/unity-applicantportal-web.yaml deleted file mode 100644 index ba55149ac..000000000 --- a/openshift/unity-applicantportal-web.yaml +++ /dev/null @@ -1,271 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -message: |- - A new application been created in your project: unity-applicantportal-web - For more information about using this template, including OpenShift considerations, - see template usage guide found in the project readme.md and wiki documents. -metadata: - name: unity-applicantportal-web - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\openshift\unity-applicantportal-web.yaml --param-file=namespace.env | oc create -f - - labels: - template: unity-applicantportal-web - annotations: - description: |- - Template for running a DotNet web application on OpenShift. - iconClass: icon-dotnet - openshift.io/display-name: DotNet web application - template.openshift.io/long-description: |- - This template defines resources needed to build and deploy a GitHub DotNet core base web application. - tags: dotnet,unity-applicantportal-web -parameters: -# Project namespace parameters -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - value: unity-applicantportal -- description: The name of the application. - displayName: Application Name - name: APPLICATION_NAME - required: true - value: unity-applicantportal-web -# Additional parameters for project application provisioning. -- description: The name of the OpenShift Service exposed for the database. - displayName: Database Service Name - name: DATABASE_SERVICE_NAME - required: true - value: unity-data-postgres -- description: Volume space available for data, e.g. 512Mi, 2Gi. - displayName: Volume Capacity - name: VOLUME_CAPACITY - required: true - value: 40Mi -- description: Git source URI for application - displayName: Git Repository URL - name: SOURCE_REPOSITORY_URL - required: true - value: https://github.com/bcgov/Unity -- description: Git branch/tag reference - displayName: Git Reference - name: SOURCE_REPOSITORY_REF - value: dev -- description: 'Custom hostname for http service route. Leave blank for default hostname, - e.g.: -.' - displayName: Custom http Route Hostname - name: HOSTNAME_HTTP - value: dev2-grants.apps.silver.devops.gov.bc.ca -- description: ASPNETCORE_ENVIRONMENT - displayName: ASPNETCORE_ENVIRONMENT - name: ASPNETCORE_ENVIRONMENT - value: Development -- description: ASPNETCORE_URLS - displayName: ASPNETCORE_URLS - name: ASPNETCORE_URLS - value: 'http://*:8080' -# Base image location -- description: The Namespace where the container image resides - displayName: Registry Namespace - name: IMAGEPULL_NAMESPACE - from: '[a-zA-Z0-9]{5}-tools' - generate: expression -- description: The ImageStream Name - displayName: Registry imagestream name - name: IMAGESTREAM_NAME - value: unity-applicantportal-build -- description: The version of the image to use, e.g. v1.0.0, v0.1.0, latest the ImageStream tag. - displayName: Application Version - name: IMAGESTREAM_TAG - required: true - value: latest -- description: The registry path of the container image used. - displayName: Registry location to pull from - name: IMAGEPULL_REGISTRY - value: image-registry.openshift-image-registry.svc:5000 -# Resource limits control how much CPU and memory a container will consume -- description: The minimum amount of CPU the Container is guaranteed. - displayName: CPU Request - name: CPU_REQUEST - required: true - value: 50m -- description: The minimum amount of Memory the Container is guaranteed. - displayName: Memory Request - name: MEMORY_REQUEST - required: true - value: 64Mi -# Template objects to instantiate the project application. -objects: -# Configmap -- apiVersion: v1 - kind: ConfigMap - metadata: - name: ${APPLICATION_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - data: - # Configuration values can be set as key-value properties - ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT} - ASPNETCORE_URLS: ${ASPNETCORE_URLS} -# Service -- apiVersion: v1 - kind: Service - metadata: - annotations: - description: The application's http port. - name: ${APPLICATION_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - ports: - - name: 80-tcp - protocol: TCP - port: 80 - targetPort: 8080 - selector: - app: ${APPLICATION_NAME} -# Route -- apiVersion: route.openshift.io/v1 - id: ${APPLICATION_NAME}-http - kind: Route - metadata: - annotations: - description: Route for application's http service. - haproxy.router.openshift.io/balance: roundrobin - haproxy.router.openshift.io/hsts_header: max-age=31536000;includeSubDomains;preload - haproxy.router.openshift.io/ip_whitelist: 142.22.0.0/15 142.24.0.0/13 142.32.0.0/14 142.36.0.0/16 - router.openshift.io/cookie-same-site: Strict - router.openshift.io/cookie_name: haproxy-uap - name: ${APPLICATION_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - host: ${HOSTNAME_HTTP} - path: / - to: - kind: Service - name: ${APPLICATION_NAME} - weight: 100 - port: - targetPort: 80-tcp - tls: - termination: edge - insecureEdgeTerminationPolicy: Redirect - wildcardPolicy: None - httpHeaders: - actions: - response: - - name: X-Frame-Options - action: - type: Set - set: - value: SAMEORIGIN - - name: X-Content-Type-Options - action: - type: Set - set: - value: no-sniff - - name: Referrer-Policy - action: - type: Set - set: - value: strict-origin-when-cross-origin - - name: Content-Security-Policy - action: - type: Set - set: - value: object-src 'none'; frame-ancestors 'none' -# Persistent storage for the application logfiles. -- apiVersion: v1 - kind: PersistentVolumeClaim - metadata: - name: ${APPLICATION_NAME}-logfiles - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: ${VOLUME_CAPACITY} - storageClassName: netapp-file-standard - volumeMode: Filesystem -# Deployment -- apiVersion: apps/v1 - kind: Deployment - metadata: - name: ${APPLICATION_NAME} - annotations: - app.openshift.io/route-disabled: "false" - app.openshift.io/vcs-ref: ${SOURCE_REPOSITORY_REF} - app.openshift.io/vcs-uri: ${SOURCE_REPOSITORY_URL} - # Add the trigger annotation - image.openshift.io/triggers: >- - [{"from":{"kind":"ImageStreamTag","name":"${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG}","namespace":"${IMAGEPULL_NAMESPACE}"},"fieldPath":"spec.template.spec.containers[?(@.name==\"${APPLICATION_NAME}\")].image","pause":"false"}] - labels: - app: ${APPLICATION_NAME} - app.openshift.io/runtime: dotnet - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - replicas: 1 - selector: - matchLabels: - app: ${APPLICATION_NAME} - strategy: - type: Recreate - template: - metadata: - labels: - app: ${APPLICATION_NAME} - spec: - volumes: - - name: ${APPLICATION_NAME}-logfiles - persistentVolumeClaim: - claimName: ${APPLICATION_NAME}-logfiles - containers: - - name: ${APPLICATION_NAME} - image: ${IMAGEPULL_REGISTRY}/${IMAGEPULL_NAMESPACE}/${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG} - imagePullPolicy: Always - resources: - requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} - ports: - - containerPort: 443 - protocol: TCP - - containerPort: 80 - protocol: TCP - env: - - name: ConnectionStrings__Default - value: >- - Host=$(UNITY_DB_HOST);port=$(UNITY_DB_PORT);Database=$(UNITY_POSTGRES_DB);Username=$(UNITY_POSTGRES_USER);Password=$(UNITY_POSTGRES_PASSWORD) - envFrom: - - configMapRef: - name: ${APPLICATION_NAME} - - configMapRef: - name: ${DATABASE_SERVICE_NAME} - - secretRef: - name: ${DATABASE_SERVICE_NAME} - volumeMounts: - - mountPath: /app/logs - name: ${APPLICATION_NAME}-logfiles - restartPolicy: Always - terminationGracePeriodSeconds: 30 - dnsPolicy: ClusterFirst diff --git a/openshift/unity-chefs-data-web.json b/openshift/unity-chefs-data-web.json deleted file mode 100644 index 1494e8e12..000000000 --- a/openshift/unity-chefs-data-web.json +++ /dev/null @@ -1,117 +0,0 @@ -{ - "kind": "Template", - "apiVersion": "template.openshift.io/v1", - "metadata": { - "name": "unity-chefs-data-web", - "annotations": { - "openshift.io/display-name": "Nginx HTTP server and a reverse proxy", - "description": "An example Nginx HTTP server and a reverse proxy (nginx) application that serves static content.", - "tags": "${APPLICATION_NAME}", - "iconClass": "icon-nginx", - "openshift.io/long-description": "This template defines resources needed to develop a static application served by Nginx HTTP server and a reverse proxy (nginx), including a build configuration and application deployment configuration." - } - }, - "message": "The following service(s) have been created in your project: ${APPLICATION_NAME}.", - "labels": { - "template": "${APPLICATION_NAME}", - "app": "${APPLICATION_NAME}" - }, - "objects": [ - { - "kind": "Route", - "apiVersion": "route.openshift.io/v1", - "metadata": { - "name": "${APPLICATION_NAME}", - "labels": { - "app.kubernetes.io/part-of": "${APPLICATION_GROUP}" - }, - "annotations": { - "haproxy.router.openshift.io/hsts_header": "max-age=31536000;includeSubDomains;preload", - "haproxy.router.openshift.io/ip_whitelist": "142.22.0.0/15 142.24.0.0/13 142.32.0.0/14 142.36.0.0/16", - "template.openshift.io/expose-uri": "http://{.spec.host}{.spec.path}" - } - }, - "spec": { - "host": "${APPLICATION_DOMAIN}", - "to": { - "kind": "Service", - "name": "${APPLICATION_SERVICE}" - }, - "httpHeaders": { - "actions": { - "request": null, - "response": [ - { - "action": { - "set": { - "value": "SAMEORIGIN" - }, - "type": "Set" - }, - "name": "X-Frame-Options" - }, - { - "action": { - "set": { - "value": "no-sniff" - }, - "type": "Set" - }, - "name": "X-Content-Type-Options" - }, - { - "action": { - "set": { - "value": "strict-origin-when-cross-origin" - }, - "type": "Set" - }, - "name": "Referrer-Policy" - }, - { - "action": { - "set": { - "value": "object-src 'none'; frame-ancestors 'none'" - }, - "type": "Set" - }, - "name": "Content-Security-Policy" - } - ] - } - }, - "tls": { - "termination": "edge", - "insecureEdgeTerminationPolicy": "Redirect" - } - } - } - ], - "parameters": [ - { - "description": "The name of the application grouping.", - "displayName": "Application Group", - "name": "APPLICATION_GROUP", - "value": "unity-tools" - }, - { - "description": "The name of the application.", - "displayName": "Application Name", - "name": "APPLICATION_NAME", - "required": true, - "value": "unity-chefs-data-web" - }, - { - "description": "The name of the service.", - "displayName": "Application Seevice", - "name": "APPLICATION_SERVICE", - "value": "unity-app-data-web" - }, - { - "name": "APPLICATION_DOMAIN", - "displayName": "Application Hostname", - "description": "The exposed hostname that will route to the nginx service, if left blank a value will be defaulted.", - "value": "dev-unity-chefs-data.apps.silver.devops.gov.bc.ca" - } - ] -} diff --git a/openshift/unity-grantmanager-build.yaml b/openshift/unity-grantmanager-build.yaml deleted file mode 100644 index 3e0166089..000000000 Binary files a/openshift/unity-grantmanager-build.yaml and /dev/null differ diff --git a/openshift/unity-grantmanager-dbmigrator-job.yaml b/openshift/unity-grantmanager-dbmigrator-job.yaml deleted file mode 100644 index 4a1b27035..000000000 --- a/openshift/unity-grantmanager-dbmigrator-job.yaml +++ /dev/null @@ -1,119 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -message: |- - A job has been created in your project: unity-grantmanager-dbmigrator-job. - For more information about using this template, including OpenShift considerations, - see template usage guide found in the project readme.md and wiki documents. -metadata: - name: unity-grantmanager-dbmigrator-job - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\openshift\unity-grantmanager-dbmigrator-job.yaml --param-file=.env | oc create -f - - labels: - template: unity-grantmanager-dbmigrator-job - annotations: - description: |- - Template for running a dotnet console application once in OpenShift. - iconClass: icon-build - openshift.io/display-name: Database Migrator Job - template.openshift.io/long-description: |- - This template defines resources needed to build and deploy a container application. - tags: dotnet,unity-grantmanager-dbmigrator -parameters: -# Project namespace parameters -- description: The name of the application. - displayName: Application Name - name: APPLICATION_NAME - required: true - value: unity-grantmanager-dbmigrator -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - value: Triggers -# Additional parameters for project application provisioning. -- description: The name of the OpenShift Service exposed for the database. - displayName: Database Service Name - name: DATABASE_SERVICE_NAME - required: true - value: unity-data-postgres -- description: Git source URI for application - displayName: Git Repository URL - name: SOURCE_REPOSITORY_URL - required: true - value: 'https://github.com/bcgov/Unity' -# Base image location -- description: The Namespace where the container image resides - displayName: Registry Namespace - name: IMAGEPULL_NAMESPACE - from: '[a-zA-Z0-9]{5}-tools' - generate: expression -- description: The ImageStream Name - displayName: Registry imagestream name - name: IMAGESTREAM_NAME - value: unity-dbmigrator-build -- description: The version of the image to use, e.g. v1.0.0, v0.1.0, latest the ImageStream tag. - displayName: Application Version - name: IMAGESTREAM_TAG - required: true - value: latest -- description: The registry path of the container image used. - displayName: Registry location to pull from - name: IMAGEPULL_REGISTRY - value: image-registry.openshift-image-registry.svc:5000 -# Resource limits -- description: The minimum amount of CPU the container is guaranteed. - displayName: CPU Request - name: CPU_REQUEST - required: true - value: 50m -- description: The minimum amount of memory the container is guaranteed. - displayName: Memory Request - name: MEMORY_REQUEST - required: true - value: 64Mi -# Template objects to instantiate the project. -objects: -# RunOnce Job for Database Migrator -- apiVersion: batch/v1 - kind: Job - metadata: - name: ${APPLICATION_NAME} - labels: - job-name: ${APPLICATION_NAME} - app.openshift.io/runtime: build - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - annotations: - app.openshift.io/vcs-uri: ${SOURCE_REPOSITORY_URL} - spec: - parallelism: 1 - completions: 1 - backoffLimit: 1 - selector: {} - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 1 - template: - metadata: - name: ${APPLICATION_NAME} - labels: - application: ${APPLICATION_NAME} - spec: - containers: - - name: ${APPLICATION_NAME} - image: ${IMAGEPULL_REGISTRY}/${IMAGEPULL_NAMESPACE}/${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG} - env: - - name: ConnectionStrings__Default - value: >- - Host=$(UNITY_DB_HOST);port=$(UNITY_DB_PORT);Database=$(UNITY_POSTGRES_DB);Username=$(UNITY_POSTGRES_USER);Password=$(UNITY_POSTGRES_PASSWORD) - - name: ConnectionStrings__Tenant - value: >- - Host=$(UNITY_DB_HOST);port=$(UNITY_DB_PORT);Database=$(UNITY_TENANT_DB);Username=$(UNITY_POSTGRES_USER);Password=$(UNITY_POSTGRES_PASSWORD) - envFrom: - - secretRef: - name: ${DATABASE_SERVICE_NAME} - resources: - requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} - restartPolicy: Never diff --git a/openshift/unity-grantmanager-pgbackup-job.yaml b/openshift/unity-grantmanager-pgbackup-job.yaml deleted file mode 100644 index 8a4b50bb5..000000000 --- a/openshift/unity-grantmanager-pgbackup-job.yaml +++ /dev/null @@ -1,141 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -message: |- - A job has been created in your project: unity-grantmanager-pgbackup-job. - For more information about using this template, including OpenShift considerations, - see template usage guide found in the project readme.md and wiki documents. -metadata: - name: unity-grantmanager-pgbackup-job - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\openshift\unity-grantmanager-pgbackup-job.yaml --param-file=pgbackup-job.env | oc create -f - - labels: - template: unity-grantmanager-pgbackup-job - annotations: - description: |- - Template for running a dotnet console application once in OpenShift. - iconClass: icon-build - openshift.io/display-name: Database Backup Job - template.openshift.io/long-description: |- - This template defines resources needed to run a Postgres-16 container application. - tags: database,postgresql -parameters: -# Project namespace parameters -- description: The name of the application. - displayName: Application Name - name: APPLICATION_NAME - required: true - value: unity-grantmanager-pgbackup -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - required: true - value: unity-grantmanager -# Additional parameters for project database provisioning. -- description: The name of the OpenShift Service exposed for the database. - displayName: Database Service Name - name: DATABASE_SERVICE_NAME - required: true - value: unity-data-postgres -- name: DATABASE_BACKUP_KEEP - description: 'Number of backups to keep' - value: '1' -- name: DATABASE_BACKUP_VOLUME_CLAIM - description: 'Name of the volume claim to be used as storage' - required: true - value: unity-data-backup -- description: The Namespace where the container image resides default=project-tools cluster=openshift, source=registry.redhat.io/rhel9/postgresql-16 - displayName: Registry Namespace - name: IMAGEPULL_NAMESPACE - from: '[a-zA-Z0-9]{5}-tools' - generate: expression -- description: The Openshift ImageStream Name - displayName: Registry imagestream name - name: IMAGESTREAM_NAME - required: true - value: postgresql-16 -- description: The version of the postgresql container image to use. - displayName: Registry container image to pull - name: IMAGESTREAM_TAG - required: true - value: latest -- description: The registry path of the postgresql container image to use. - displayName: Registry container image to pull - name: IMAGEPULL_REGISTRY - required: true - value: image-registry.apps.silver.devops.gov.bc.ca -# Resource limits -- description: The minimum amount of CPU the container is guaranteed. - displayName: CPU Request - name: CPU_REQUEST - required: true - value: 50m -- description: The minimum amount of memory the container is guaranteed. - displayName: Memory Request - name: MEMORY_REQUEST - required: true - value: 64Mi -# Template objects to instantiate the project. -objects: -# RunOnce Job for Database Backups -- apiVersion: batch/v1 - kind: Job - metadata: - name: ${APPLICATION_NAME} - labels: - job-name: ${APPLICATION_NAME} - app.openshift.io/runtime: build - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - parallelism: 1 - completions: 1 - backoffLimit: 1 - selector: {} - successfulJobsHistoryLimit: 1 - failedJobsHistoryLimit: 1 - template: - metadata: - name: ${APPLICATION_NAME} - labels: - application: ${APPLICATION_NAME} - spec: - volumes: - - name: ${APPLICATION_NAME} - persistentVolumeClaim: - claimName: ${DATABASE_BACKUP_VOLUME_CLAIM} - containers: - - name: ${APPLICATION_NAME} - image: ${IMAGEPULL_REGISTRY}/${IMAGEPULL_NAMESPACE}/${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG} - command: - - 'bash' - - '-eo' - - 'pipefail' - - '-c' - - > - trap "echo Backup failed; exit 0" ERR; date; - FILENAME=dumpall-${DATABASE_SERVICE_NAME}-`date +%Y-%m-%d_%H%M%S`.sql.gz; - time (find /var/lib/pgsql/backups -type f -name "*-${DATABASE_SERVICE_NAME}-*" -exec ls -1tr "{}" + | head -n -$DATABASE_BACKUP_KEEP | xargs rm -fr; - PGPASSWORD="$UNITY_POSTGRES_PASSWORD" pg_dumpall --username=$UNITY_POSTGRES_USER --host=$UNITY_DB_HOST --port=$UNITY_DB_PORT --column-inserts --clean | gzip > /var/lib/pgsql/backups/$FILENAME); - echo "";echo "Backup successful";du -h /var/lib/pgsql/backups/$FILENAME; - echo "";echo "to restore the backup use: $ psql --username=$UNITY_POSTGRES_USER --password --host=$UNITY_DB_HOST --port=$UNITY_DB_PORT --username postgres < /var/lib/pgsql/backups/ (unpacked with gunzip)"; - echo "";ls -lR /var/lib/pgsql/backups - ## Add single and mapped environment values - env: - - name: DATABASE_BACKUP_KEEP - value: ${DATABASE_BACKUP_KEEP} - - name: TZ - value: Canada/Pacific - envFrom: - ## Add all from ${DATABASE_SERVICE_NAME} - - secretRef: - name: ${DATABASE_SERVICE_NAME} - volumeMounts: - - name: ${APPLICATION_NAME} - mountPath: /var/lib/pgsql/backups - resources: - requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} - restartPolicy: Never diff --git a/openshift/unity-grantmanager-web.yaml b/openshift/unity-grantmanager-web.yaml deleted file mode 100644 index 8c37fc3e2..000000000 --- a/openshift/unity-grantmanager-web.yaml +++ /dev/null @@ -1,517 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -message: |- - A new application been created in your project: unity-grantmanager-web - For more information about using this template, including OpenShift considerations, - see template usage guide found in the project readme.md and wiki documents. -metadata: - name: unity-grantmanager-web - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\openshift\unity-grantmanager-web.yaml --param-file=namespace.env | oc create -f - - labels: - template: unity-grantmanager-web - annotations: - description: |- - Template for running a DotNet web application on OpenShift. - iconClass: icon-dotnet - openshift.io/display-name: DotNet web application - template.openshift.io/long-description: |- - This template defines resources needed to build and deploy a GitHub DotNet core base web application. - tags: dotnet,unity-grantmanager-web -parameters: -# Project namespace parameters -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - value: unity-grantmanager -- description: The name of the application. - displayName: Application Name - name: APPLICATION_NAME - required: true - value: unity-grantmanager-web -# Additional parameters for project application provisioning. -- description: The name of the OpenShift Service exposed for the database. - displayName: Database Service Name - name: DATABASE_SERVICE_NAME - required: true - value: unity-data-postgres -- description: The name of the storage object. - displayName: Object Storage Name - name: STORAGE_OBJECT_NAME - required: true - value: s3-object-storage -- description: Volume space available for data, e.g. 512Mi, 2Gi. - displayName: Volume Capacity - name: VOLUME_CAPACITY - required: true - value: 128Mi -- description: Git source URI for application - displayName: Git Repository URL - name: SOURCE_REPOSITORY_URL - required: true - value: https://github.com/bcgov/Unity -- description: Git branch/tag reference - displayName: Git Reference - name: SOURCE_REPOSITORY_REF - value: dev -- description: 'Custom hostname for http service route. Leave blank for default hostname, - e.g.: -.' - displayName: Custom http Route Hostname - name: HOSTNAME_HTTP - value: develop-unity.apps.silver.devops.gov.bc.ca -- description: ASPNETCORE_ENVIRONMENT - displayName: ASPNETCORE_ENVIRONMENT - name: ASPNETCORE_ENVIRONMENT - value: Development -- description: ASPNETCORE_URLS - displayName: ASPNETCORE_URLS - name: ASPNETCORE_URLS - value: 'http://*:8080' -- description: StringEncryption__DefaultPassPhrase - displayName: StringEncryption__DefaultPassPhrase - from: '[a-zA-Z0-9]{16}' - generate: expression - name: StringEncryption__DefaultPassPhrase - required: true -- description: AuthServer__ClientId - displayName: AuthServer__ClientId - from: '[a-zA-Z0-9]{16}' - generate: expression - name: AuthServer__ClientId - required: true -- description: AuthServer__ClientSecret - displayName: AuthServer__ClientSecret - from: 'unity-[0-9]{4}' - generate: expression - name: AuthServer__ClientSecret - required: true -- description: AuthServer__Audience - displayName: AuthServer__Audience - from: 'unity-[0-9]{4}' - generate: expression - name: AuthServer__Audience - required: true -- description: AuthServer__ServerAddress - displayName: AuthServer__ServerAddress - name: AuthServer__ServerAddress - value: 'https://dev.loginproxy.gov.bc.ca/auth' -- description: Intake__BaseUri - displayName: Intake__BaseUri - name: Intake__BaseUri - value: 'https://submit.digital.gov.bc.ca/app/api/v1' -- description: CssApi__ClientId - displayName: CssApi__ClientId - name: CssApi__ClientId - from: 'service-account-[0-9]{4}-[0-9]{4}' - generate: expression -- description: CssApi__ClientSecret - displayName: CssApi__ClientSecret - name: CssApi__ClientSecret - from: '[a-zA-Z0-9]{32}' - generate: expression - required: true -- description: CssApi__TokenUrl - displayName: CssApi__TokenUrl - name: CssApi__TokenUrl - value: 'https://loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/token' -- description: CssApi__Url - displayName: CssApi__Url - name: CssApi__Url - value: 'https://api.loginproxy.gov.bc.ca/api/v1' -- description: CssApi__Env - displayName: CssApi__Env - name: CssApi__Env - value: dev -- description: Notifications__TeamsNotificationsWebhook - displayName: Notifications__TeamsNotificationsWebhook - name: Notifications__TeamsNotificationsWebhook -- description: Notifications__ChesClientSecret - displayName: Notifications__ChesClientSecret - name: Notifications__ChesClientSecret - from: '[a-zA-Z0-9]{32}' - generate: expression - required: true -- description: Notifications__ChesClientId - displayName: Notifications__ChesClientId - from: '[a-zA-Z0-9]{16}' - generate: expression - name: Notifications__ChesClientId - required: true -- description: Notifications__ChesTokenUrl - displayName: Notifications__ChesTokenUrl - name: Notifications__ChesTokenUrl - value: 'https://dev.loginproxy.gov.bc.ca/auth/realms/comsvcauth/protocol/openid-connect/token' -- description: Notifications__ChesUrl - displayName: Notifications__ChesUrl - name: Notifications__ChesUrl - value: 'https://ches-dev.api.gov.bc.ca/api/v1' -- description: Notifications__ChesFromEmail - displayName: Notifications__ChesFromEmail - name: Notifications__ChesFromEmail - value: 'unity-noreply@gov.bc.ca' -- description: Payments__CasBaseUrl - displayName: Payments__CasBaseUrl - name: Payments__CasBaseUrl - value: 'https://cfs-systws.cas.gov.bc.ca:7025/ords/cas' -- description: Payments__CasClientSecret - displayName: Payments__CasClientSecret - from: '[a-zA-Z0-9]{22}..' - generate: expression - name: Payments__CasClientSecret -- description: Payments__CasClientId - displayName: Payments__CasClientId - from: '[a-zA-Z0-9]{22}..' - generate: expression - name: Payments__CasClientId -- description: RabbitMQ__Password - displayName: RabbitMQ__Password - from: '[a-zA-Z0-9]{26}' - generate: expression - name: RabbitMQ__Password -- description: RabbitMQ__UserName - displayName: RabbitMQ__UserName - value: 'unity-rabbitmq-user-dev' - name: RabbitMQ__UserName -- description: RabbitMQ__VirtualHost - displayName: RabbitMQ__VirtualHost - value: 'dev' - name: RabbitMQ__VirtualHost -- description: RabbitMQ__HostName - displayName: RabbitMQ__HostName - value: 'unity-rabbitmq' - name: RabbitMQ__HostName -- description: Redis__Configuration - displayName: Redis__Configuration - from: 'dev-redis-ha.[a-zA-Z0-9]{5}-dev.svc.cluster.local:26379' - generate: expression - name: Redis__Configuration -- description: Redis__HostName - displayName: Redis__HostName - value: 'dev-redis-ha' - name: Redis__HostName -- description: Redis__IsEnabled - displayName: Redis__IsEnabled - value: 'false' - name: Redis__IsEnabled -# Base image location -- description: The Namespace where the container image resides - displayName: Registry Namespace - name: IMAGEPULL_NAMESPACE - from: '[a-zA-Z0-9]{5}-tools' - generate: expression -- description: The ImageStream Name - displayName: Registry imagestream name - name: IMAGESTREAM_NAME - value: unity-grantmanager-build -- description: The version of the image to use, e.g. v1.0.0, v0.1.0, latest the ImageStream tag. - displayName: Application Version - name: IMAGESTREAM_TAG - required: true - value: latest -- description: The registry path of the container image used. - displayName: Registry location to pull from - name: IMAGEPULL_REGISTRY - value: image-registry.openshift-image-registry.svc:5000 -# Resources control how much CPU and memory a container will consume -- description: The minimum amount of CPU the Container is guaranteed. - displayName: CPU Request - name: CPU_REQUEST - required: true - value: 50m -- description: The minimum amount of Memory the Container is guaranteed. - displayName: Memory Request - name: MEMORY_REQUEST - required: true - value: 128Mi -# Template objects to instantiate the project application. -objects: -# Secrets -- apiVersion: v1 - kind: Secret - metadata: - name: ${APPLICATION_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - stringData: - StringEncryption__DefaultPassPhrase: ${StringEncryption__DefaultPassPhrase} - AuthServer__ClientId: ${AuthServer__ClientId} - AuthServer__ClientSecret: ${AuthServer__ClientSecret} - AuthServer__Audience: ${AuthServer__Audience} - CssApi__ClientId: ${CssApi__ClientId} - CssApi__ClientSecret: ${CssApi__ClientSecret} - Notifications__TeamsNotificationsWebhook: ${Notifications__TeamsNotificationsWebhook} - Notifications__ChesClientId: ${Notifications__ChesClientId} - Notifications__ChesClientSecret: ${Notifications__ChesClientSecret} - Payments__CasClientSecret: ${Payments__CasClientSecret} - Payments__CasClientId: ${Payments__CasClientId} - RabbitMQ__Password: ${RabbitMQ__Password} - type: Opaque -# Configmap -- apiVersion: v1 - kind: ConfigMap - metadata: - name: ${APPLICATION_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - data: - # Configuration values can be set as key-value properties - ASPNETCORE_ENVIRONMENT: ${ASPNETCORE_ENVIRONMENT} - ASPNETCORE_URLS: ${ASPNETCORE_URLS} - AuthServer__IsBehindTlsTerminationProxy: 'true' - AuthServer__Realm: standard - AuthServer__RequireHttpsMetadata: 'false' - AuthServer__ServerAddress: ${AuthServer__ServerAddress} - BackgroundJobs__CasPaymentsReconciliation__ConsumerExpression: '0 0 14 1/1 * ? *' - BackgroundJobs__CasPaymentsReconciliation__ProducerExpression: '0 0 13 1/1 * ? *' - BackgroundJobs__EmailResend__Expression: '0 0/5 * * * ?' - BackgroundJobs__EmailResend__RetryAttemptsMaximum: '2' - BackgroundJobs__IsJobExecutionEnabled: 'true' - BackgroundJobs__Quartz__IsAutoRegisterEnabled: 'true' - BackgroundJobs__IntakeResync__NumDaysToCheck: '-2' - BackgroundJobs__IntakeResync__Expression: '0 0 23 1/1 * ? *' - BackgroundJobs__Quartz__UseCluster: ${Redis__IsEnabled} - CssApi__TokenUrl: ${CssApi__TokenUrl} - CssApi__Url: ${CssApi__Url} - CssApi__Env: ${CssApi__Env} - Intake__BaseUri: ${Intake__BaseUri} - Notifications__ChesTokenUrl: ${Notifications__ChesTokenUrl} - Notifications__ChesUrl: ${Notifications__ChesUrl} - Notifications__ChesFromEmail: ${Notifications__ChesFromEmail} - Payments__CasBaseUrl: ${Payments__CasBaseUrl} - RabbitMQ__UserName: ${RabbitMQ__UserName} - RabbitMQ__VirtualHost: ${RabbitMQ__VirtualHost} - RabbitMQ__HostName: ${RabbitMQ__HostName} - DataProtection__IsEnabled: ${Redis__IsEnabled} - Redis__Configuration: ${Redis__Configuration} - Redis__DatabaseId: '0' - Redis__Host: ${Redis__HostName} - Redis__InstanceName: ${Redis__HostName} - Redis__IsEnabled: ${Redis__IsEnabled} - Redis__KeyPrefix: unity - Redis__Port: '6379' - Redis__SentinelMasterName: redisMasterSet - Redis__UseSentinel: ${Redis__IsEnabled} - Serilog__MinimumLevel__Override__Quartz.Impl: Information - Serilog__MinimumLevel__Override__Quartz.SQL: Information -# Services -- apiVersion: v1 - kind: Service - metadata: - annotations: - description: The application's http port. - name: ${APPLICATION_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - ports: - - name: 80-tcp - protocol: TCP - port: 80 - targetPort: 8080 - selector: - app: ${APPLICATION_NAME} -# Route ingress -- apiVersion: route.openshift.io/v1 - id: ${APPLICATION_NAME}-http - kind: Route - metadata: - annotations: - description: Route for application's http service. - haproxy.router.openshift.io/balance: roundrobin - haproxy.router.openshift.io/hsts_header: max-age=31536000;includeSubDomains;preload - router.openshift.io/cookie-same-site: Strict - router.openshift.io/cookie_name: haproxy-ugm - name: ${APPLICATION_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - host: ${HOSTNAME_HTTP} - path: / - to: - kind: Service - name: ${APPLICATION_NAME} - weight: 100 - port: - targetPort: 80-tcp - tls: - termination: edge - insecureEdgeTerminationPolicy: Redirect - wildcardPolicy: None - httpHeaders: - actions: - response: - - name: X-Frame-Options - action: - type: Set - set: - value: SAMEORIGIN - - name: X-Content-Type-Options - action: - type: Set - set: - value: no-sniff - - name: Referrer-Policy - action: - type: Set - set: - value: strict-origin-when-cross-origin - - name: Content-Security-Policy - action: - type: Set - set: - value: object-src 'none'; frame-ancestors 'none' -# Persistent storage for the application logfiles -- apiVersion: v1 - kind: PersistentVolumeClaim - metadata: - name: ${APPLICATION_NAME}-logfiles - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - accessModes: - - ReadWriteMany - resources: - requests: - storage: ${VOLUME_CAPACITY} - storageClassName: netapp-file-standard - volumeMode: Filesystem -# Deployment -- apiVersion: apps/v1 - kind: Deployment - metadata: - name: ${APPLICATION_NAME} - annotations: - app.openshift.io/route-disabled: "false" - app.openshift.io/vcs-ref: ${SOURCE_REPOSITORY_REF} - app.openshift.io/vcs-uri: ${SOURCE_REPOSITORY_URL} - image.openshift.io/triggers: >- - [{"from":{"kind":"ImageStreamTag","name":"${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG}","namespace":"${IMAGEPULL_NAMESPACE}"},"fieldPath":"spec.template.spec.containers[?(@.name==\"${APPLICATION_NAME}\")].image","pause":"false"}] - labels: - app: ${APPLICATION_NAME} - app.openshift.io/runtime: dotnet - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - replicas: 3 - selector: - matchLabels: - app: ${APPLICATION_NAME} - strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 2 - maxUnavailable: 1 - template: - metadata: - labels: - application: ${APPLICATION_NAME} - app: ${APPLICATION_NAME} - spec: - volumes: - - name: ${APPLICATION_NAME}-logfiles - persistentVolumeClaim: - claimName: ${APPLICATION_NAME}-logfiles - containers: - - name: ${APPLICATION_NAME} - image: ${IMAGEPULL_REGISTRY}/${IMAGEPULL_NAMESPACE}/${IMAGESTREAM_NAME}:${IMAGESTREAM_TAG} - imagePullPolicy: Always - env: - - name: ConnectionStrings__Default - value: >- - Host=$(UNITY_DB_HOST);port=$(UNITY_DB_PORT);Database=$(UNITY_POSTGRES_DB);Username=$(UNITY_POSTGRES_USER);Password=$(UNITY_POSTGRES_PASSWORD) - - name: ConnectionStrings__Tenant - value: >- - Host=$(UNITY_DB_HOST);port=$(UNITY_DB_PORT);Database=$(UNITY_TENANT_DB);Username=$(UNITY_POSTGRES_USER);Password=$(UNITY_POSTGRES_PASSWORD) - - name: Redis__Password - valueFrom: - secretKeyRef: - name: ${Redis__HostName} - key: database-password - envFrom: - - configMapRef: - name: ${APPLICATION_NAME} - - secretRef: - name: ${APPLICATION_NAME} - - secretRef: - name: ${DATABASE_SERVICE_NAME} - - configMapRef: - name: ${STORAGE_OBJECT_NAME} - - secretRef: - name: ${STORAGE_OBJECT_NAME} - resources: - requests: - cpu: ${CPU_REQUEST} - memory: ${MEMORY_REQUEST} - readinessProbe: - httpGet: - path: /healthz/ready - port: 8080 - scheme: HTTP - httpHeaders: - - name: content-type - value: text/plain - - name: readiness - value: healthy - timeoutSeconds: 5 - periodSeconds: 30 - successThreshold: 1 - failureThreshold: 3 - livenessProbe: - httpGet: - path: /healthz/live - port: 8080 - scheme: HTTP - httpHeaders: - - name: content-type - value: text/plain - initialDelaySeconds: 120 - timeoutSeconds: 5 - periodSeconds: 30 - successThreshold: 1 - failureThreshold: 3 - startupProbe: - httpGet: - path: /healthz/startup - port: 8080 - scheme: HTTP - httpHeaders: - - name: content-type - value: text/plain - initialDelaySeconds: 30 - timeoutSeconds: 1 - periodSeconds: 5 - successThreshold: 1 - failureThreshold: 12 - ports: - - containerPort: 443 - protocol: TCP - - containerPort: 80 - protocol: TCP - volumeMounts: - - mountPath: /app/logs - name: ${APPLICATION_NAME}-logfiles - restartPolicy: Always - terminationGracePeriodSeconds: 30 - dnsPolicy: ClusterFirst diff --git a/openshift/unity-image-puller.yaml b/openshift/unity-image-puller.yaml deleted file mode 100644 index 38a31fbea..000000000 --- a/openshift/unity-image-puller.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# oc import-image rhel9/postgresql-15:1-28.1697636666 --from=registry.redhat.io/rhel9/postgresql-15:1-28.1697636666 --confirm -kind: RoleBinding -apiVersion: rbac.authorization.k8s.io/v1 -metadata: - name: 'system:image-puller' - namespace: ${PROJECT_NAMESPACE}-tools -subjects: - - kind: ServiceAccount - name: default - namespace: ${PROJECT_NAMESPACE}-dev - - kind: ServiceAccount - name: default - namespace: ${PROJECT_NAMESPACE}-test - - kind: ServiceAccount - name: default - namespace: ${PROJECT_NAMESPACE}-prod -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: 'system:image-puller' diff --git a/openshift/unity-imagestream.yaml b/openshift/unity-imagestream.yaml deleted file mode 100644 index e3351196c..000000000 Binary files a/openshift/unity-imagestream.yaml and /dev/null differ diff --git a/openshift/unity-metabase.yaml b/openshift/unity-metabase.yaml deleted file mode 100644 index 33b4bc97e..000000000 Binary files a/openshift/unity-metabase.yaml and /dev/null differ diff --git a/openshift/unity-networkpolicy.yaml b/openshift/unity-networkpolicy.yaml deleted file mode 100644 index 530bdd06e..000000000 --- a/openshift/unity-networkpolicy.yaml +++ /dev/null @@ -1,80 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -metadata: - name: unity-networkpolicy - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\openshift\unity-network-policy.yaml --param-file=.env | oc create -f - - labels: - template: unity-networkpolicy - annotations: - description: |- - Template for communications rules in OpenShift. -parameters: -# Project namespace parameters -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - value: unity-grantmanager -- description: The name of the application. - displayName: Application Name - name: APPLICATION_NAME - required: true - value: unity-grantmanager-web -# Template objects to instantiate the project application. -objects: - - kind: NetworkPolicy - apiVersion: networking.k8s.io/v1 - metadata: - name: deny-by-default - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - # The default posture for a security first namespace is to - # deny all traffic. If not added this rule will be added - # by Platform Services during environment cut-over. - podSelector: {} - ingress: [] - - apiVersion: networking.k8s.io/v1 - kind: NetworkPolicy - metadata: - name: allow-from-openshift-ingress - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - # This policy allows any pod with a route & service combination - # to accept traffic from the OpenShift router pods. This is - # required for things outside of OpenShift (like the Internet) - # to reach your pods. - ingress: - - from: - - namespaceSelector: - matchLabels: - network.openshift.io/policy-group: ingress - podSelector: {} - policyTypes: - - Ingress - - kind: NetworkPolicy - apiVersion: networking.k8s.io/v1 - metadata: - name: allow-same-namespace - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - spec: - # Allow all pods within the current namespace to communicate - # to one another. - podSelector: - ingress: - - from: - - podSelector: {} diff --git a/openshift/unity-rabbitmq.yaml b/openshift/unity-rabbitmq.yaml deleted file mode 100644 index 7b1da3782..000000000 Binary files a/openshift/unity-rabbitmq.yaml and /dev/null differ diff --git a/openshift/unity-s3-object-storage.yaml b/openshift/unity-s3-object-storage.yaml deleted file mode 100644 index a3b8ffac9..000000000 --- a/openshift/unity-s3-object-storage.yaml +++ /dev/null @@ -1,94 +0,0 @@ -apiVersion: template.openshift.io/v1 -kind: Template -metadata: - name: unity-s3-object-storage - # This template uses a separate parameter .env file to override the default values defined in this section. - # oc process -f .\openshift\unity-s3-object-storage.yaml --param-file=.env | oc create -f - - labels: - template: unity-s3-object-storage - annotations: - description: |- - Template for S3 connection information in OpenShift. -parameters: -# Project namespace parameters -- description: The name of the application grouping. - displayName: Application Group - name: APPLICATION_GROUP - value: unity-grantmanager -- description: The name of the application. - displayName: Application Name - name: APPLICATION_NAME - required: true - value: unity-grantmanager-web -# Additional parameters for S3 object storage -- description: The name of the application. - displayName: Application Name - name: STORAGE_OBJECT_NAME - required: true - value: s3-object-storage -- name: AccessKeyID - displayName: "Access Key Login ID" - description: "The Access Key for S3 compatible object storage account" - from: '[A-Z0-9]{20}_default' - generate: expression -- name: BucketName - displayName: "Bucket Name" - description: "The object storage bucket name" - required: true - value: "econ-unity-dev" -- name: Endpoint - displayName: "API endpoint for S3 compatible storage account" - description: "Object store URL. eg: https://econ.objectstore.gov.bc.ca" - required: true - value: "https://econ.objectstore.gov.bc.ca" -- name: SecretKey - displayName: "Secret Key" - description: "S3 account Secret Access Key, similar to a password." - from: '[\w]{32}_default' - generate: expression -- name: ApplicationFolder - displayName: ApplicationFolder - description: "The object storage Application Folder name" - required: true - value: "Unity/Application" -- name: AssessmentFolder - displayName: AssessmentFolder - description: "The object storage Assessment Folder name" - required: true - value: "Unity/Adjudication" -# Template objects to instantiate the project. -objects: -# Secrets -- apiVersion: v1 - kind: Secret - metadata: - name: ${STORAGE_OBJECT_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - stringData: - S3__AccessKeyId: ${AccessKeyID} - S3__Bucket: ${BucketName} - S3__SecretAccessKey: ${SecretKey} - type: Opaque -# Configmap -- apiVersion: v1 - kind: ConfigMap - metadata: - name: ${STORAGE_OBJECT_NAME} - labels: - app: ${APPLICATION_NAME} - app.kubernetes.io/component: ${APPLICATION_NAME} - app.kubernetes.io/instance: ${APPLICATION_NAME}-1 - app.kubernetes.io/name: ${APPLICATION_NAME} - app.kubernetes.io/part-of: ${APPLICATION_GROUP} - data: - # Configuration values can be set as key-value properties - S3__Endpoint: ${Endpoint} - S3__ApplicationS3Folder: ${ApplicationFolder} - S3__AssessmentS3Folder: ${AssessmentFolder} - S3__DisallowedFileTypes: '[ "exe" , "sh" , "ksh" , "bat" , "cmd" ]' - S3__MaxFileSize: '25' diff --git a/openshift/unity-sysdig-team.yaml b/openshift/unity-sysdig-team.yaml deleted file mode 100644 index 6b99c636c..000000000 --- a/openshift/unity-sysdig-team.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: ops.gov.bc.ca/v1alpha1 -kind: SysdigTeam -metadata: - name: ${PROJECT_NAMESPACE}-sysdigteam - namespace: ${PROJECT_NAMESPACE}-tools -spec: - team: - description: The Sysdig Team for the OpenShift Project Set Unity - users: - - name: first.last@gov.bc.ca - role: ROLE_TEAM_EDIT - - name: first.last@gov.bc.ca - role: ROLE_TEAM_EDIT - - name: first.last@gov.bc.ca - role: ROLE_TEAM_EDIT