diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts
new file mode 100644
index 000000000..886fecb2f
--- /dev/null
+++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts
@@ -0,0 +1,512 @@
+///
+
+// cypress/e2e/chefsdata.cy.ts
+
+describe('Unity Login and check data from CHEFS', () => {
+ const STANDARD_TIMEOUT = 20000
+
+ function switchToDefaultGrantsProgramIfAvailable() {
+ cy.get('body').then(($body) => {
+ const hasUserInitials = $body.find('.unity-user-initials').length > 0
+
+ if (!hasUserInitials) {
+ cy.log('Skipping tenant switch: no user initials menu found')
+ return
+ }
+
+ cy.get('.unity-user-initials').click()
+
+ cy.get('body').then(($body2) => {
+ const switchLink = $body2.find('#user-dropdown a.dropdown-item').filter((_, el) => {
+ return (el.textContent || '').trim() === 'Switch Grant Programs'
+ })
+
+ if (switchLink.length === 0) {
+ cy.log('Skipping tenant switch: "Switch Grant Programs" not present for this user/session')
+ cy.get('body').click(0, 0)
+ return
+ }
+
+ cy.wrap(switchLink.first()).click()
+
+ cy.url({ timeout: STANDARD_TIMEOUT }).should('include', '/GrantPrograms')
+
+ cy.get('#search-grant-programs', { timeout: STANDARD_TIMEOUT })
+ .should('be.visible')
+ .clear()
+ .type('Default Grants Program')
+
+ // Flatten nested `within` usage to satisfy S2004 (limit nesting depth)
+ cy.contains('#UserGrantProgramsTable tbody tr', 'Default Grants Program', { timeout: STANDARD_TIMEOUT })
+ .should('exist')
+ .within(() => {
+ cy.contains('button', 'Select')
+ .should('be.enabled')
+ .click()
+ })
+
+ cy.location('pathname', { timeout: STANDARD_TIMEOUT }).should((p) => {
+ expect(p.indexOf('/GrantApplications') >= 0 || p.indexOf('/auth/') >= 0).to.eq(true)
+ })
+ })
+ })
+ }
+
+
+ // TEST renders the Submission tab inside an open shadow root (Form.io).
+ // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs.
+ before(() => {
+ Cypress.config('includeShadowDom', true)
+ })
+
+ it('Verify Login', () => {
+ // 1.) Always start from the base URL
+ cy.visit(Cypress.env('webapp.url'))
+
+ // 2.) Decide auth path based on visible UI
+ cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => {
+ // Already authenticated
+ if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) {
+ cy.contains('VIEW APPLICATIONS', { timeout: STANDARD_TIMEOUT }).click({ force: true })
+ return
+ }
+
+ // Not authenticated
+ if ($body.find('button:contains("LOGIN")').length > 0) {
+ cy.contains('LOGIN', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true })
+ cy.contains('IDIR', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true })
+
+ cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($loginBody) => {
+ // Perform IDIR login only if prompted
+ if ($loginBody.find('#user').length > 0) {
+ cy.get('#user', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1username'))
+ cy.get('#password', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1password'))
+ cy.contains('Continue', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true })
+ } else {
+ cy.log('Already logged in')
+ }
+ })
+
+ return
+ }
+
+ // Fail loudly if neither state is detectable
+ throw new Error('Unable to determine authentication state')
+ })
+
+ // 3.) Post-condition check
+ cy.url({ timeout: STANDARD_TIMEOUT }).should('include', '/GrantApplications')
+ })
+
+ it('Switch to Default Grants Program if available', () => {
+ switchToDefaultGrantsProgramIfAvailable()
+ })
+
+ it('Handle IDIR if required', () => {
+ cy.get('body').then(($body) => {
+ if ($body.find('#social-idir').length > 0) {
+ cy.get('#social-idir').should('be.visible').click()
+ }
+ })
+
+ cy.location('pathname', { timeout: 30000 }).should('include', '/GrantApplications')
+ })
+
+ it('Tests the existence and functionality of the Submitted Date From and Submitted Date To filters', () => {
+
+ const pad2 = (n: number) => String(n).padStart(2, '0');
+
+ const todayIsoLocal = () => {
+ const d = new Date();
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+ };
+
+ const waitForRefresh = () => {
+ // S3923 fix: remove identical branches; assert spinner is hidden when present.
+ cy.get('div.spinner-grow[role="status"]', { timeout: STANDARD_TIMEOUT })
+ .then(($s) => {
+ cy.wrap($s).should('have.attr', 'style').and('contain', 'display: none');
+ });
+ };
+
+ // --- Submitted Date From ---
+ cy.get('input#submittedFromDate', { timeout: STANDARD_TIMEOUT })
+ .click({ force: true })
+ .clear({ force: true })
+ .type('2022-01-01', { force: true })
+ .trigger('change', { force: true })
+ .blur({ force: true })
+ .should('have.value', '2022-01-01');
+
+ waitForRefresh();
+
+ // --- Submitted Date To ---
+ const today = todayIsoLocal();
+
+ cy.get('input#submittedToDate', { timeout: STANDARD_TIMEOUT })
+ .click({ force: true })
+ .clear({ force: true })
+ .type(today, { force: true })
+ .trigger('change', { force: true })
+ .blur({ force: true })
+ .should('have.value', today);
+
+ waitForRefresh();
+
+ });
+
+ // With no rows selected verify the visibility of Filter, Export, Save View, and Columns.
+ it('Verify the action buttons are visible with no rows selected', () => {
+
+ })
+
+ // With one row selected verify the visibility of Filter, Export, Save View, and Columns.
+ it('Verify the action buttons are visible with one row selected', () => {
+
+ })
+
+ it('Clicks Payment and force-closes the modal', () => {
+ const BUTTON_TIMEOUT = 60000;
+
+ // Ensure table has rows
+ cy.get('.dt-scroll-body tbody tr', { timeout: STANDARD_TIMEOUT })
+ .should('have.length.greaterThan', 1);
+
+ // Select two rows using non-link cells
+ const clickSelectableCell = (rowIdx: number, withCtrl = false) => {
+ cy.get('.dt-scroll-body tbody tr', { timeout: STANDARD_TIMEOUT })
+ .eq(rowIdx)
+ .find('td')
+ .not(':has(a)')
+ .first()
+ .click({ force: true, ctrlKey: withCtrl });
+ };
+ clickSelectableCell(0);
+ clickSelectableCell(1, true);
+
+ // ActionBar
+ cy.get('#app_custom_buttons', { timeout: STANDARD_TIMEOUT })
+ .should('exist')
+ .scrollIntoView();
+
+ // Click Payment
+ cy.get('#applicationPaymentRequest', { timeout: BUTTON_TIMEOUT })
+ .should('be.visible')
+ .and('not.be.disabled')
+ .click({ force: true });
+
+ // Wait until modal is shown
+ cy.get('#payment-modal', { timeout: STANDARD_TIMEOUT })
+ .should('be.visible')
+ .and('have.class', 'show');
+
+ // Attempt graceful closes first
+ cy.get('body').type('{esc}', { force: true }); // Bootstrap listens to ESC
+ cy.get('.modal-backdrop', { timeout: STANDARD_TIMEOUT }).then(($bd) => {
+ if ($bd.length) {
+ cy.wrap($bd).click('topLeft', { force: true });
+ }
+ });
+
+ // Try footer Cancel if available (avoid .catch on Cypress chainable)
+ cy.contains('#payment-modal .modal-footer button', 'Cancel', { timeout: STANDARD_TIMEOUT })
+ .then(($btn) => {
+ if ($btn && $btn.length > 0) {
+ cy.wrap($btn).scrollIntoView().click({ force: true });
+ } else {
+ cy.log('Cancel button not present, proceeding to hard-close fallback');
+ }
+ });
+
+ // Use window API (if present), then hard-close fallback
+ cy.window().then((win: any) => {
+ try {
+ if (typeof win.closePaymentModal === 'function') {
+ win.closePaymentModal();
+ }
+ } catch { /* ignore */ }
+
+ // HARD CLOSE: forcibly hide modal and remove backdrop
+ const $ = (win as any).jQuery || (win as any).$;
+ if ($) {
+ try {
+ $('#payment-modal')
+ .removeClass('show')
+ .attr('aria-hidden', 'true')
+ .css('display', 'none');
+ $('.modal-backdrop').remove();
+ $('body').removeClass('modal-open').css('overflow', ''); // restore scroll
+ } catch { /* ignore */ }
+ }
+ });
+
+ // Verify modal/backdrop gone (be tolerant: assert non-interference instead of visibility only)
+ cy.get('#payment-modal', { timeout: STANDARD_TIMEOUT }).should(($m) => {
+ const isHidden = !$m.is(':visible') || !$m.hasClass('show');
+ expect(isHidden, 'payment-modal hidden or not shown').to.eq(true);
+ });
+ cy.get('.modal-backdrop', { timeout: STANDARD_TIMEOUT }).should('not.exist');
+
+ // Right-side buttons usable
+ cy.get('#dynamicButtonContainerId', { timeout: STANDARD_TIMEOUT })
+ .should('exist')
+ .scrollIntoView();
+
+ cy.contains('#dynamicButtonContainerId .dt-buttons button span', 'Export', { timeout: STANDARD_TIMEOUT }).should('be.visible');
+ cy.contains('#dynamicButtonContainerId button.grp-savedStates', 'Save View', { timeout: STANDARD_TIMEOUT }).should('be.visible');
+ cy.contains('#dynamicButtonContainerId .dt-buttons button span', 'Columns', { timeout: STANDARD_TIMEOUT }).should('be.visible');
+ });
+
+
+ // Walk the Columns menu and toggle each column on, verifying the column is visibile.
+ it('Verify all columns in the menu are visible when and toggled on.', () => {
+ const clickColumnsItem = (label: string) => {
+ cy.contains('a.dropdown-item', label, { timeout: STANDARD_TIMEOUT })
+ .should('exist')
+ .scrollIntoView()
+ .click({ force: true })
+ }
+
+ const getVisibleHeaderTitles = () => {
+ return cy.get('.dt-scroll-head span.dt-column-title', { timeout: STANDARD_TIMEOUT }).then(($els) => {
+ const titles = Cypress.$($els)
+ .toArray()
+ .map((el) => (el.textContent || '').replace(/\s+/g, ' ').trim())
+ .filter((t) => t.length > 0)
+ return titles
+ })
+ }
+
+ const assertVisibleHeadersInclude = (expected: string[]) => {
+ getVisibleHeaderTitles().then((titles) => {
+ expected.forEach((e) => {
+ expect(titles, `visible headers should include "${e}"`).to.include(e)
+ })
+ })
+ }
+
+ const scrollX = (x: number) => {
+ cy.get('.dt-scroll-body', { timeout: STANDARD_TIMEOUT })
+ .should('exist')
+ .scrollTo(x, 0, { duration: 0, ensureScrollable: false })
+ }
+
+ // Open the "Save View" dropdown
+ cy.get('button.grp-savedStates', { timeout: STANDARD_TIMEOUT })
+ .should('be.visible')
+ .and('contain.text', 'Save View')
+ .click()
+
+ // Click "Reset to Default View"
+ cy.contains('a.dropdown-item', 'Reset to Default View', { timeout: STANDARD_TIMEOUT })
+ .should('exist')
+ .click({ force: true })
+
+ // Open Columns menu
+ cy.contains('span', 'Columns', { timeout: STANDARD_TIMEOUT })
+ .should('be.visible')
+ .click()
+
+ clickColumnsItem('% of Total Project Budget')
+ clickColumnsItem('Acquisition')
+ clickColumnsItem('Applicant Electoral District')
+
+ clickColumnsItem('Applicant Id')
+ clickColumnsItem('Applicant Id')
+
+ clickColumnsItem('Applicant Name')
+ clickColumnsItem('Applicant Name')
+
+ clickColumnsItem('Approved Amount')
+ clickColumnsItem('Approved Amount')
+
+ clickColumnsItem('Assessment Result')
+
+ clickColumnsItem('Assignee')
+ clickColumnsItem('Assignee')
+
+ clickColumnsItem('Business Number')
+
+ clickColumnsItem('Category')
+ clickColumnsItem('Category')
+
+ clickColumnsItem('City')
+
+ clickColumnsItem('Community')
+ clickColumnsItem('Community')
+
+ clickColumnsItem('Community Population')
+ clickColumnsItem('Contact Business Phone')
+ clickColumnsItem('Contact Cell Phone')
+ clickColumnsItem('Contact Email')
+ clickColumnsItem('Contact Full Name')
+ clickColumnsItem('Contact Title')
+ clickColumnsItem('Decision Date')
+ clickColumnsItem('Decline Rationale')
+ clickColumnsItem('Due Date')
+ clickColumnsItem('Due Diligence Status')
+ clickColumnsItem('Economic Region')
+ clickColumnsItem('Forestry Focus')
+ clickColumnsItem('Forestry or Non-Forestry')
+ clickColumnsItem('FYE Day')
+ clickColumnsItem('FYE Month')
+ clickColumnsItem('Indigenous')
+ clickColumnsItem('Likelihood of Funding')
+ clickColumnsItem('Non-Registered Organization Name')
+ clickColumnsItem('Notes')
+ clickColumnsItem('Org Book Status')
+ clickColumnsItem('Organization Type')
+ clickColumnsItem('Other Sector/Sub/Industry Description')
+ clickColumnsItem('Owner')
+ clickColumnsItem('Payout')
+ clickColumnsItem('Place')
+ clickColumnsItem('Project Electoral District')
+ clickColumnsItem('Project End Date')
+
+ clickColumnsItem('Project Name')
+ clickColumnsItem('Project Name')
+
+ clickColumnsItem('Project Start Date')
+ clickColumnsItem('Project Summary')
+ clickColumnsItem('Projected Funding Total')
+ clickColumnsItem('Recommended Amount')
+ clickColumnsItem('Red-Stop')
+ clickColumnsItem('Regional District')
+ clickColumnsItem('Registered Organization Name')
+ clickColumnsItem('Registered Organization Number')
+
+ clickColumnsItem('Requested Amount')
+ clickColumnsItem('Requested Amount')
+
+ clickColumnsItem('Risk Ranking')
+ clickColumnsItem('Sector')
+ clickColumnsItem('Signing Authority Business Phone')
+ clickColumnsItem('Signing Authority Cell Phone')
+ clickColumnsItem('Signing Authority Email')
+ clickColumnsItem('Signing Authority Full Name')
+ clickColumnsItem('Signing Authority Title')
+
+ clickColumnsItem('Status')
+ clickColumnsItem('Status')
+
+ clickColumnsItem('Sub-Status')
+ clickColumnsItem('Sub-Status')
+
+ clickColumnsItem('Submission #')
+ clickColumnsItem('Submission #')
+
+ clickColumnsItem('Submission Date')
+ clickColumnsItem('Submission Date')
+
+ clickColumnsItem('SubSector')
+
+ clickColumnsItem('Tags')
+ clickColumnsItem('Tags')
+
+ clickColumnsItem('Total Paid Amount $')
+ clickColumnsItem('Total Project Budget')
+ clickColumnsItem('Total Score')
+ clickColumnsItem('Unity Application Id')
+
+ // Close the menu and wait until the overlay is gone
+ cy.get('div.dt-button-background', { timeout: STANDARD_TIMEOUT })
+ .should('exist')
+ .click({ force: true })
+
+ cy.get('div.dt-button-background', { timeout: STANDARD_TIMEOUT }).should('not.exist')
+
+ // Assertions by horizontal scroll segments (human-style scan)
+ scrollX(0)
+ assertVisibleHeadersInclude([
+ 'Applicant Name',
+ 'Category',
+ 'Submission #',
+ 'Submission Date',
+ 'Status',
+ 'Sub-Status',
+ 'Community',
+ 'Requested Amount',
+ 'Approved Amount',
+ 'Project Name',
+ 'Applicant Id',
+ ])
+
+ scrollX(1500)
+ assertVisibleHeadersInclude([
+ 'Tags',
+ 'Assignee',
+ 'SubSector',
+ 'Economic Region',
+ 'Regional District',
+ 'Registered Organization Number',
+ 'Org Book Status',
+ ])
+
+ scrollX(3000)
+ assertVisibleHeadersInclude([
+ 'Project Start Date',
+ 'Project End Date',
+ 'Projected Funding Total',
+ 'Total Paid Amount $',
+ 'Project Electoral District',
+ 'Applicant Electoral District',
+ ])
+
+ scrollX(4500)
+ assertVisibleHeadersInclude([
+ 'Forestry or Non-Forestry',
+ 'Forestry Focus',
+ 'Acquisition',
+ 'City',
+ 'Community Population',
+ 'Likelihood of Funding',
+ 'Total Score',
+ ])
+
+ scrollX(6000)
+ assertVisibleHeadersInclude([
+ 'Assessment Result',
+ 'Recommended Amount',
+ 'Due Date',
+ 'Owner',
+ 'Decision Date',
+ 'Project Summary',
+ 'Organization Type',
+ 'Business Number',
+ ])
+
+ scrollX(7500)
+ assertVisibleHeadersInclude([
+ 'Due Diligence Status',
+ 'Decline Rationale',
+ 'Contact Full Name',
+ 'Contact Title',
+ 'Contact Email',
+ 'Contact Business Phone',
+ 'Contact Cell Phone',
+ ])
+
+ scrollX(9000)
+ assertVisibleHeadersInclude([
+ 'Signing Authority Full Name',
+ 'Signing Authority Title',
+ 'Signing Authority Email',
+ 'Signing Authority Business Phone',
+ 'Signing Authority Cell Phone',
+ 'Place',
+ 'Risk Ranking',
+ 'Notes',
+ 'Red-Stop',
+ 'Indigenous',
+ 'FYE Day',
+ 'FYE Month',
+ 'Payout',
+ 'Unity Application Id',
+ ])
+ })
+
+
+ it('Verify Logout', () => {
+ cy.logout()
+ })
+})
diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts
index c0f6d2120..0c2c6dc12 100644
--- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts
+++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts
@@ -33,12 +33,55 @@ describe('Send an email', () => {
const TEST_EMAIL_SUBJECT = `Smoke Test Email ${timestamp}`
+ function ensureLoggedInToGrantApplications() {
+ // Headless runs specs sequentially in the same browser process.
+ // Do not assume logged-out or logged-in. Detect UI state like chefsdata.cy.ts does.
+ cy.visit(Cypress.env('webapp.url'))
+
+ cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => {
+ // Already authenticated
+ if ($body.find('button:contains("VIEW APPLICATIONS")').length > 0) {
+ cy.contains('VIEW APPLICATIONS', { timeout: STANDARD_TIMEOUT }).click({ force: true })
+ return
+ }
+
+ // Not authenticated
+ if ($body.find('button:contains("LOGIN")').length > 0) {
+ cy.contains('LOGIN', { timeout: STANDARD_TIMEOUT }).should('exist').click({ force: true })
+
+ cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($loginBody) => {
+ // IDIR chooser may or may not appear
+ if ($loginBody.find(':contains("IDIR")').length > 0) {
+ cy.contains('IDIR', { timeout: STANDARD_TIMEOUT }).click({ force: true })
+ }
+
+ cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($authBody) => {
+ // Only type creds if the login form is actually present
+ if ($authBody.find('#user').length > 0) {
+ cy.get('#user', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1username'))
+ cy.get('#password', { timeout: STANDARD_TIMEOUT }).type(Cypress.env('test1password'))
+ cy.contains('Continue', { timeout: STANDARD_TIMEOUT }).click({ force: true })
+ } else {
+ cy.log('Already authenticated')
+ }
+ })
+ })
+
+ return
+ }
+
+ throw new Error('Unable to determine authentication state')
+ })
+
+ cy.location('pathname', { timeout: 30000 }).should('include', '/GrantApplications')
+ }
+
function switchToDefaultGrantsProgramIfAvailable() {
cy.get('body').then(($body) => {
const hasUserInitials = $body.find('.unity-user-initials').length > 0
if (!hasUserInitials) {
- cy.log('Skipping tenant switch: no user initials menu found')
+ cy.log('Skipping tenant: no user initials menu found')
return
}
@@ -50,7 +93,7 @@ describe('Send an email', () => {
})
if (switchLink.length === 0) {
- cy.log('Skipping tenant switch: "Switch Grant Programs" not present for this user/session')
+ cy.log('Skipping tenant: "Switch Grant Programs" not present for this user/session')
cy.get('body').click(0, 0)
return
}
@@ -97,7 +140,6 @@ describe('Send an email', () => {
return
}
- // Fallback: find the subject anywhere in a TD (scoped to avoid brittle class names)
cy.contains('td', subject, { timeout: STANDARD_TIMEOUT })
.should('exist')
.click()
@@ -105,7 +147,6 @@ describe('Send an email', () => {
}
function confirmSendDialogIfPresent() {
- // Wait until either a bootstrap modal is shown, or SweetAlert container appears, or confirm button exists.
cy.get('body', { timeout: STANDARD_TIMEOUT }).should(($b) => {
const hasBootstrapShownModal = $b.find('.modal.show').length > 0
const hasSwal = $b.find('.swal2-container').length > 0
@@ -116,11 +157,9 @@ describe('Send an email', () => {
cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($b) => {
const hasSwal = $b.find('.swal2-container').length > 0
if (hasSwal) {
- // SweetAlert2 style
cy.get('.swal2-container', { timeout: STANDARD_TIMEOUT }).should('be.visible')
cy.contains('.swal2-container', 'Are you sure', { timeout: STANDARD_TIMEOUT }).should('exist')
- // Typical confirm button class, with fallback to text match
if ($b.find('.swal2-confirm').length > 0) {
cy.get('.swal2-confirm', { timeout: STANDARD_TIMEOUT }).should('be.visible').click()
} else {
@@ -131,14 +170,12 @@ describe('Send an email', () => {
const hasBootstrapShownModal = $b.find('.modal.show').length > 0
if (hasBootstrapShownModal) {
- // Bootstrap modal: assert the shown modal, not the inner content div
cy.get('.modal.show', { timeout: STANDARD_TIMEOUT })
.should('be.visible')
.within(() => {
cy.contains('Are you sure you want to send this email?', { timeout: STANDARD_TIMEOUT })
.should('exist')
- // Prefer the known id if present, otherwise click a button with expected intent text
if (Cypress.$('#btn-confirm-send').length > 0) {
cy.get('#btn-confirm-send', { timeout: STANDARD_TIMEOUT })
.should('exist')
@@ -151,7 +188,6 @@ describe('Send an email', () => {
return
}
- // Last resort: confirm button exists but modal might not be "visible" by Cypress standards
cy.get('#btn-confirm-send', { timeout: STANDARD_TIMEOUT })
.should('exist')
.click({ force: true })
@@ -159,7 +195,7 @@ describe('Send an email', () => {
}
it('Login', () => {
- cy.login()
+ ensureLoggedInToGrantApplications()
})
it('Switch to Default Grants Program if available', () => {
diff --git a/applications/Unity.GrantManager/docs/ApplicantPortalIntegration.md b/applications/Unity.GrantManager/docs/ApplicantPortalIntegration.md
new file mode 100644
index 000000000..c47190351
--- /dev/null
+++ b/applications/Unity.GrantManager/docs/ApplicantPortalIntegration.md
@@ -0,0 +1,1562 @@
+# Applicant Portal Integration Guide
+
+## Overview
+
+The Unity Grant Manager integrates with the Applicant Portal through two primary mechanisms:
+
+1. **REST API Endpoints** - Synchronous queries for profile and tenant information
+2. **RabbitMQ Messaging** - Asynchronous commands and events for data synchronization
+
+This dual-integration approach provides both immediate data access (API) and reliable, decoupled communication (messaging) between the systems.
+
+## Architecture
+
+```mermaid
+graph TD
+ A[Applicant Portal
External System] -->|HTTPS + API Key| B[Unity Grant Manager
HttpApi Layer]
+ B -->|GET /api/app/applicant-profiles/profile| B
+ B -->|GET /api/app/applicant-profiles/tenants| B
+ B --> C[ApplicantProfileAppService
Business Logic]
+ C --> D[Host Database
AppApplicantTenantMaps Table
Centralized Lookup]
+
+ style A fill:#e1f5ff
+ style B fill:#fff4e6
+ style C fill:#f3e5f5
+ style D fill:#e8f5e9
+```
+
+### Data Synchronization Flow
+
+The `AppApplicantTenantMaps` table is kept in sync through two mechanisms:
+
+1. **Real-time Updates** (Event Handler)
+ - Triggered when a submission is received
+ - `UpdateApplicantProfileCacheHandler` handles `ApplicationProcessEvent`
+ - Creates or updates mapping immediately
+
+2. **Nightly Reconciliation** (Background Job)
+ - Runs daily at 2 AM PST (10 AM UTC)
+ - `ApplicantTenantMapReconciliationWorker` scans all tenants
+ - Ensures no mappings are missed
+ - Self-healing mechanism
+
+## API Endpoints
+
+### Base URL
+```
+https://{unity-host}/api/app/applicant-profiles
+```
+
+### Authentication
+All endpoints require API Key authentication using the `X-Api-Key` header.
+
+**Request Header**:
+```
+X-Api-Key: {your-api-key}
+```
+
+**Error Response** (401 Unauthorized):
+```json
+{
+ "type": "https://tools.ietf.org/html/rfc7235#section-3.1",
+ "title": "Unauthorized",
+ "status": 401,
+ "detail": "API Key missing"
+}
+```
+
+---
+
+### 1. Get Applicant Profile
+
+Retrieves basic profile information for an applicant.
+
+**Endpoint**: `GET /api/app/applicant-profiles/profile`
+
+**Query Parameters**:
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `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 |
+
+**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
+X-Api-Key: your-api-key-here
+```
+
+**Response Example** (200 OK):
+```json
+{
+ "profileId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ "subject": "smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299",
+ "email": "applicant@example.com",
+ "displayName": "John Doe"
+}
+```
+
+**Response Schema**:
+```csharp
+public class ApplicantProfileDto
+{
+ public Guid ProfileId { get; set; }
+ public string Subject { get; set; }
+ public string Email { get; set; }
+ public string DisplayName { get; set; }
+}
+```
+
+---
+
+### 2. Get Applicant Tenants
+
+Retrieves the list of tenants (grant programs) the applicant has submitted applications to.
+
+**Endpoint**: `GET /api/app/applicant-profiles/tenants`
+
+**Query Parameters**:
+| Parameter | Type | Required | Description |
+|-----------|------|----------|-------------|
+| `ProfileId` | `Guid` | Yes | Unique identifier for the applicant profile |
+| `Subject` | `string` | Yes | OIDC subject identifier (e.g., `user@idp`) |
+
+**Request Example**:
+``` http
+GET /api/app/applicant-profiles/tenants?ProfileId=3fa85f64-5717-4562-b3fc-2c963f66afa6&Subject=smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299
+X-Api-Key: your-api-key-here
+```
+
+**Response Example** (200 OK):
+```json
+[
+ {
+ "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
+ "tenantName": "Housing Grant Program"
+ },
+ {
+ "tenantId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "tenantName": "Business Development Fund"
+ }
+]
+```
+
+**Response Schema**:
+```csharp
+public class ApplicantTenantDto
+{
+ public Guid TenantId { get; set; }
+ public string TenantName { get; set; }
+}
+```
+
+**Empty Response** (No tenants found):
+```json
+[]
+```
+
+---
+
+## Subject Identifier Format
+
+The system extracts and normalizes OIDC subject identifiers as follows:
+
+### Input Formats Supported
+1. **From CHEFS Submission**:
+ - Path: `submission.data.hiddenApplicantAgent.sub`
+ - Example: `"smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299"`
+
+2. **From CreatedBy Field**:
+ - Path: `submission.createdBy`
+ - Example: `"anonymous@bcservicescard"`
+
+### Normalization Rules
+1. Extract the identifier **before** the `@` symbol
+2. Convert to **UPPERCASE**
+3. Store in `AppApplicantTenantMaps.OidcSubUsername`
+
+**Examples**:
+- `smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299` ? `SMZFRRLA7J5HW6Z7WZVYZDRTQ6DJ6FBR`
+- `anonymous@bcservicescard` ? `ANONYMOUS`
+
+**Implementation**: See `IntakeSubmissionHelper.ExtractOidcSub(dynamic submission)`
+
+---
+
+## Data Model
+
+### AppApplicantTenantMaps Table (Host Database)
+
+**Table Schema**:
+```sql
+CREATE TABLE "ApplicantTenantMaps" (
+ "Id" UUID PRIMARY KEY,
+ "OidcSubUsername" VARCHAR NOT NULL,
+ "TenantId" UUID NOT NULL,
+ "TenantName" VARCHAR NOT NULL,
+ "LastUpdated" TIMESTAMP NOT NULL,
+ "CreationTime" TIMESTAMP NOT NULL,
+ "CreatorId" UUID,
+ CONSTRAINT "UQ_ApplicantTenantMaps_OidcSub_Tenant"
+ UNIQUE ("OidcSubUsername", "TenantId")
+);
+
+CREATE INDEX "IX_ApplicantTenantMaps_OidcSubUsername"
+ ON "ApplicantTenantMaps" ("OidcSubUsername");
+```
+
+**Entity**: `ApplicantTenantMap`
+```csharp
+public class ApplicantTenantMap : CreationAuditedAggregateRoot
+{
+ public string OidcSubUsername { get; set; } // Normalized (uppercase) username
+ public Guid TenantId { get; set; }
+ public string TenantName { get; set; }
+ public DateTime LastUpdated { get; set; }
+}
+```
+
+**Key Characteristics**:
+- **Stored in Host Database**: Single source of truth across all tenants
+- **Unique Constraint**: One mapping per (OidcSubUsername, TenantId) pair
+- **Indexed**: Fast lookups by OidcSubUsername
+- **Audited**: Tracks creation time and creator
+
+---
+
+## Data Synchronization
+
+### 1. Real-time Sync (Event Handler)
+
+**Class**: `UpdateApplicantProfileCacheHandler`
+**Location**: `src/Unity.GrantManager.Application/Intakes/Handlers/UpdateApplicantProfileCacheHandler.cs`
+
+**Trigger**: `ApplicationProcessEvent` (fires when a submission is received)
+
+**Flow**:
+```mermaid
+graph TD
+ A[Submission Received] --> B[ApplicationProcessEvent Fired]
+ B --> C[UpdateApplicantProfileCacheHandler]
+ C --> D[Extract OidcSub from submission]
+ D --> E[Normalize to uppercase username]
+ E --> F[Query Host DB for existing mapping]
+ F --> G{Mapping Exists?}
+ G -->|Yes| H[Update LastUpdated]
+ G -->|No| I[Create New Mapping]
+ H --> J[Success]
+ I --> J
+
+ style A fill:#e3f2fd
+ style J fill:#c8e6c9
+```
+
+**Code Example**:
+```csharp
+public async Task HandleEventAsync(ApplicationProcessEvent eventData)
+{
+ var submission = eventData.ApplicationFormSubmission;
+ var subUsername = ExtractAndNormalize(submission.OidcSub);
+
+ using (currentTenant.Change(null)) // Switch to Host DB
+ {
+ var existingMapping = await FindMapping(subUsername, tenantId);
+
+ if (existingMapping != null)
+ {
+ existingMapping.LastUpdated = DateTime.UtcNow;
+ await UpdateAsync(existingMapping);
+ }
+ else
+ {
+ var newMapping = new ApplicantTenantMap
+ {
+ OidcSubUsername = subUsername,
+ TenantId = tenantId,
+ TenantName = tenantName,
+ LastUpdated = DateTime.UtcNow
+ };
+ await InsertAsync(newMapping);
+ }
+ }
+}
+```
+
+**Benefits**:
+- **Immediate**: Mapping available within milliseconds
+- **Lightweight**: Single row insert/update per submission
+- **Idempotent**: Safe to run multiple times
+
+---
+
+### 2. Nightly Reconciliation (Background Job)
+
+**Class**: `ApplicantTenantMapReconciliationWorker`
+**Location**: `src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs`
+
+**Schedule**: Daily at 2 AM PST (10 AM UTC)
+**Configurable via**: `GrantManager.BackgroundJobs.ApplicantTenantMapReconciliation_Expression`
+
+**Flow**:
+```mermaid
+graph TD
+ A[Scheduled Trigger
2 AM PST] --> B[For each Tenant]
+ B --> C[Get all distinct OidcSub values
from tenant submissions]
+ C --> D[For each OidcSub]
+ D --> E[Check if mapping exists
in Host DB]
+ E --> F{Mapping Exists?}
+ F -->|Yes| G[Update LastUpdated]
+ F -->|No| H[Create New Mapping]
+ G --> I[Next OidcSub/Tenant]
+ H --> I
+ I --> J{More Records?}
+ J -->|Yes| D
+ J -->|No| K[Log Statistics
Created/Updated counts]
+
+ style A fill:#fff9c4
+ style K fill:#c8e6c9
+```
+
+**Purpose**:
+- **Self-healing**: Catches any missed events or failures
+- **Data validation**: Ensures consistency across tenants
+- **New tenant support**: Automatically includes new tenants
+- **Historical backfill**: Populates mappings for existing data
+
+**Configuration** (appsettings.json):
+```json
+{
+ "Settings": {
+ "GrantManager.BackgroundJobs.ApplicantTenantMapReconciliation_Expression": "0 0 10 1/1 * ? *"
+ }
+}
+```
+
+**Testing Configuration** (Every 2 minutes):
+```json
+{
+ "Settings": {
+ "GrantManager.BackgroundJobs.ApplicantTenantMapReconciliation_Expression": "0 */2 * * * ?"
+ }
+}
+```
+
+**Logs Example**:
+```
+[INF] Starting ApplicantTenantMap reconciliation...
+[DBG] Processing tenant: Housing Grant Program
+[INF] Created missing ApplicantTenantMap for SMZFRRLA7J5HW6Z7WZVYZDRTQ6DJ6FBR in tenant Housing Grant Program
+[DBG] Processing tenant: Business Development Fund
+[INF] ApplicantTenantMap reconciliation completed. Created: 5, Updated: 127
+```
+
+---
+
+## API Key Authentication
+
+### Setup
+
+**Class**: `ApiKeyAuthorizationFilter`
+**Location**: `src/Unity.GrantManager.HttpApi/Controllers/Authentication/ApiKeyAuthorizationFilter.cs`
+
+### Configuration
+
+**appsettings.json**:
+```json
+{
+ "B2BAuth": {
+ "ApiKey": "your-secure-api-key-here"
+ }
+}
+```
+
+**Environment Variable** (Recommended for production):
+```bash
+B2BAuth__ApiKey=your-secure-api-key-here
+```
+
+**User Secrets** (Development):
+```bash
+dotnet user-secrets set "B2BAuth:ApiKey" "your-secure-api-key-here"
+```
+
+### Usage
+
+The `ApiKeyAuthorizationFilter` is applied to the controller via `[ServiceFilter]`:
+
+```csharp
+[ApiController]
+[Route("api/app/applicant-profiles")]
+[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
+public class ApplicantProfileController : AbpControllerBase
+{
+ // Endpoints protected by API key
+}
+```
+
+### Request Format
+
+**Header**:
+```
+X-Api-Key: your-secure-api-key-here
+```
+
+**cURL Example**:
+```bash
+curl -X GET "https://unity.example.com/api/app/applicant-profiles/tenants?ProfileId=3fa85f64-5717-4562-b3fc-2c963f66afa6&Subject=user@idp" \
+ -H "X-Api-Key: your-secure-api-key-here"
+```
+
+### Security Best Practices
+
+1. **Key Generation**: Use cryptographically secure random strings (min 32 characters)
+2. **Storage**: Store in Azure Key Vault or equivalent secret management system
+3. **Rotation**: Rotate keys regularly (e.g., every 90 days)
+4. **Transport**: Always use HTTPS in production
+5. **Logging**: Avoid logging API keys (filter logs appropriately)
+6. **Rate Limiting**: Consider implementing rate limiting per API key
+
+---
+
+## RabbitMQ Messaging Integration
+
+### Overview
+
+In addition to REST API endpoints, Unity Grant Manager and the Applicant Portal communicate asynchronously via RabbitMQ messaging. This enables reliable, decoupled communication for operations that don't require immediate responses, such as data synchronization, notifications, and workflow orchestration.
+
+### Architecture Pattern: Inbox/Outbox
+
+The Applicant Portal implements the **Transactional Outbox** and **Inbox** patterns for reliable message processing:
+
+```mermaid
+graph TB
+ subgraph Portal[Applicant Portal]
+ POT[Outbox Table]
+ PIT[Inbox Table]
+ PWO[Background Worker:
Outbox Publisher]
+ PWI[Background Worker:
Inbox Consumer]
+ end
+
+ subgraph Broker[RabbitMQ Broker]
+ QC[applicant-portal.commands]
+ QE[unity.events]
+ QD[applicant-portal.dlq]
+ end
+
+ subgraph Unity[Unity Grant Manager]
+ UCH[Command Handlers]
+ UEP[Event Publishers]
+ end
+
+ POT --> PWO
+ PWO -->|Publish| QC
+ QC --> UCH
+ UCH -->|Publish| QE
+ QE --> PWI
+ PWI --> PIT
+
+ QC -.->|Failed| QD
+ QE -.->|Failed| QD
+
+ style Portal fill:#e1f5ff
+ style Broker fill:#fff4e6
+ style Unity fill:#f3e5f5
+```
+
+**Key Benefits**:
+- **Reliability**: Messages persist in database before sending/processing
+- **Transactional Consistency**: Database and message operations are atomic
+- **At-Least-Once Delivery**: Messages guaranteed not to be lost
+- **Idempotency**: Handlers must handle duplicate messages
+- **Observability**: Messages tracked in database tables
+
+### Message Flow Patterns
+
+#### 1. Commands (Applicant Portal ? Unity)
+
+Commands represent requests from the Applicant Portal for Unity to perform an action.
+
+**Flow**:
+```mermaid
+sequenceDiagram
+ participant PBL as Portal Business Logic
+ participant OT as Outbox Table
+ participant BW as Background Worker
+ participant RMQ as RabbitMQ Queue
+ participant UIT as Unity Inbox
+ participant UC as Unity Command Handler
+
+ PBL->>OT: Save to Outbox (Transaction)
+ PBL->>OT: Commit Transaction
+ BW->>OT: Poll for pending messages
+ BW->>RMQ: Publish to Queue
+ BW->>OT: Mark as Sent
+ RMQ->>UIT: Consume from Queue
+ UIT->>UIT: Save to Inbox
+ UIT->>UC: Process Command
+ UC->>RMQ: Acknowledge Message
+
+ Note over OT,UC: At-Least-Once Delivery
+```
+
+**Example Commands** (Future Implementation):
+
+- **UpdateApplicantProfileCommand**
+ - Trigger: User updates profile in Portal
+ - Action: Unity updates applicant record
+
+- **SubmitApplicationDraftCommand**
+ - Trigger: User saves draft application
+ - Action: Unity creates/updates draft
+
+- **RequestApplicationStatusCommand**
+ - Trigger: User requests status update
+ - Action: Unity publishes current status event
+
+#### 2. Events (Unity ? Applicant Portal)
+
+Events represent notifications about things that have happened in Unity.
+
+**Flow**:
+```mermaid
+sequenceDiagram
+ participant UBL as Unity Business Logic
+ participant DE as Domain Event
+ participant EH as Event Handler
+ participant RMQ as RabbitMQ
+ participant PC as Portal Consumer
+ participant PIT as Portal Inbox Table
+ participant BW as Background Worker
+
+ UBL->>DE: Raise Domain Event
+ DE->>EH: Trigger Event Handler
+ EH->>RMQ: Publish to Queue
+ RMQ->>PC: Consume from Queue
+ PC->>PIT: Save to Inbox (Transaction)
+ PC->>RMQ: Acknowledge Message
+ BW->>PIT: Poll Inbox
+ BW->>BW: Process Event
+ BW->>PIT: Mark as Processed
+
+ Note over UBL,BW: Reliable Event Delivery
+```
+
+**Example Events** (Future Implementation):
+
+- **ApplicationStatusChangedEvent**
+ - Trigger: Application status changes in Unity
+ - Action: Portal updates user's application view
+
+- **ApplicationAssignedEvent**
+ - Trigger: Application assigned to reviewer
+ - Action: Portal updates applicant notifications
+
+- **PaymentApprovedEvent**
+ - Trigger: Payment approved in Unity
+ - Action: Portal notifies applicant
+
+- **DocumentRequestedEvent**
+ - Trigger: Unity requests additional documents
+ - Action: Portal creates notification for user
+
+---
+
+### Queue Structure
+
+#### Queue Naming Convention
+```
+{system}.{messageType}.{entityType}
+```
+
+**Examples**:
+- `applicant-portal.commands.application`
+- `unity.events.application-status`
+- `applicant-portal.commands.profile`
+
+#### Core Queues
+
+| Queue Name | Direction | Purpose | DLQ |
+|------------|-----------|---------|-----|
+| `applicant-portal.commands` | Portal -> Unity | Commands from portal | `applicant-portal.commands.dlq` |
+| `unity.events.application` | Unity -> Portal | Application-related events | `unity.events.application.dlq` |
+| `unity.events.payment` | Unity -> Portal | Payment-related events | `unity.events.payment.dlq` |
+| `unity.events.notification` | Unity -> Portal | Notification events | `unity.events.notification.dlq` |
+
+**Dead Letter Queues (DLQ)**:
+- Messages that fail processing after retry attempts
+- Manual inspection and reprocessing required
+- Monitoring alerts trigger on DLQ depth
+
+---
+
+### Message Format
+
+#### Standard Message Envelope
+
+All messages follow a common envelope structure:
+
+```json
+{
+ "messageId": "uuid",
+ "correlationId": "uuid",
+ "causationId": "uuid",
+ "messageType": "string",
+ "timestamp": "ISO8601",
+ "source": "applicant-portal|unity",
+ "version": "1.0",
+ "payload": {
+ // Message-specific data
+ },
+ "metadata": {
+ "userId": "string",
+ "tenantId": "guid",
+ "environment": "dev|test|prod"
+ }
+}
+```
+
+**Field Descriptions**:
+- **messageId**: Unique identifier for this message (for idempotency)
+- **correlationId**: Groups related messages (e.g., all messages for one application)
+- **causationId**: ID of the message that caused this message
+- **messageType**: Fully qualified message type name
+- **timestamp**: When message was created (UTC)
+- **source**: Originating system
+- **version**: Message schema version
+- **payload**: Message-specific data
+- **metadata**: Contextual information
+
+#### Example Command Message
+
+**UpdateApplicantProfileCommand**:
+```json
+{
+ "messageId": "550e8400-e29b-41d4-a716-446655440000",
+ "correlationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "causationId": null,
+ "messageType": "UpdateApplicantProfileCommand",
+ "timestamp": "2026-01-15T22:42:24.115Z",
+ "source": "applicant-portal",
+ "version": "1.0",
+ "payload": {
+ "applicantId": "guid",
+ "oidcSubject": "smzfrrla7j5hw6z7wzvyzdrtq6dj6fbr@chefs-frontend-5299",
+ "email": "user@example.com",
+ "displayName": "John Doe",
+ "phoneNumber": "(123) 555-1234",
+ "address": {
+ "street1": "123 Main St",
+ "city": "Victoria",
+ "province": "BC",
+ "postalCode": "V8V 1A1"
+ }
+ },
+ "metadata": {
+ "userId": "guid",
+ "tenantId": "guid",
+ "environment": "dev"
+ }
+}
+```
+
+#### Example Event Message
+
+**ApplicationStatusChangedEvent**:
+```json
+{
+ "messageId": "123e4567-e89b-12d3-a456-426614174000",
+ "correlationId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "causationId": "550e8400-e29b-41d4-a716-446655440000",
+ "messageType": "ApplicationStatusChangedEvent",
+ "timestamp": "2026-01-15T23:10:15.220Z",
+ "source": "unity",
+ "version": "1.0",
+ "payload": {
+ "applicationId": "guid",
+ "applicantOidcSub": "SMZFRRLA7J5HW6Z7WZVYZDRTQ6DJ6FBR",
+ "tenantId": "guid",
+ "tenantName": "Housing Grant Program",
+ "previousStatus": "UnderReview",
+ "currentStatus": "Approved",
+ "statusChangedAt": "2026-01-15T23:10:15.220Z",
+ "statusChangedBy": "reviewer@gov.bc.ca",
+ "reason": "Application meets all criteria"
+ },
+ "metadata": {
+ "userId": "reviewer-guid",
+ "tenantId": "guid",
+ "environment": "dev"
+ }
+}
+```
+
+---
+
+### Message Processing
+
+#### Idempotency
+
+All message handlers must be idempotent to handle duplicate messages safely.
+
+**Implementation Strategies**:
+
+1. **Message ID Tracking**:
+```csharp
+public async Task HandleAsync(ApplicationStatusChangedEvent message)
+{
+ // Check if message already processed
+ if (await _processedMessagesRepository.ExistsAsync(message.MessageId))
+ {
+ _logger.LogWarning("Duplicate message {MessageId} ignored", message.MessageId);
+ return; // Already processed
+ }
+
+ // Process message
+ await ProcessMessageAsync(message);
+
+ // Record as processed
+ await _processedMessagesRepository.InsertAsync(new ProcessedMessage
+ {
+ MessageId = message.MessageId,
+ ProcessedAt = DateTime.UtcNow
+ });
+}
+```
+
+2. **Natural Idempotency**:
+```csharp
+public async Task HandleAsync(UpdateApplicantProfileCommand command)
+{
+ // Upsert operation is naturally idempotent
+ var applicant = await _repository.FindAsync(command.ApplicantId)
+ ?? new Applicant { Id = command.ApplicantId };
+
+ applicant.Email = command.Payload.Email;
+ applicant.DisplayName = command.Payload.DisplayName;
+
+ await _repository.UpsertAsync(applicant);
+}
+```
+
+#### Retry Strategy
+
+**Exponential Backoff**:
+```
+Attempt 1: Immediate
+Attempt 2: 5 seconds
+Attempt 3: 25 seconds (5 * 5)
+Attempt 4: 125 seconds (25 * 5)
+Attempt 5: Move to DLQ
+```
+
+**Configuration**:
+```json
+{
+ "RabbitMQ": {
+ "RetryPolicy": {
+ "MaxRetries": 5,
+ "InitialDelaySeconds": 5,
+ "BackoffMultiplier": 5,
+ "MaxDelaySeconds": 300
+ }
+ }
+}
+```
+
+#### Error Handling
+
+**Transient Errors** (Retry):
+- Database connection timeouts
+- Temporary network issues
+- Rate limiting (429)
+
+**Permanent Errors** (Move to DLQ):
+- Invalid message format
+- Business rule violations
+- Missing referenced entities
+
+**Example Handler**:
+```csharp
+public async Task HandleAsync(ApplicationCommand command)
+{
+ try
+ {
+ await _applicationService.ProcessCommandAsync(command);
+ return MessageProcessingResult.Success;
+ }
+ catch (TransientException ex)
+ {
+ _logger.LogWarning(ex, "Transient error processing message {MessageId}", command.MessageId);
+ return MessageProcessingResult.Retry;
+ }
+ catch (PermanentException ex)
+ {
+ _logger.LogError(ex, "Permanent error processing message {MessageId}", command.MessageId);
+ return MessageProcessingResult.DeadLetter;
+ }
+}
+```
+
+---
+
+### Configuration
+
+#### RabbitMQ Connection
+
+**appsettings.json**:
+```json
+{
+ "RabbitMQ": {
+ "HostName": "rabbitmq.example.com",
+ "Port": 5672,
+ "VirtualHost": "/",
+ "UserName": "unity-service",
+ "Password": "***",
+ "UseSsl": true,
+ "Queues": {
+ "Commands": "applicant-portal.commands",
+ "ApplicationEvents": "unity.events.application",
+ "PaymentEvents": "unity.events.payment",
+ "NotificationEvents": "unity.events.notification"
+ },
+ "Exchanges": {
+ "Commands": "applicant-portal.commands.exchange",
+ "Events": "unity.events.exchange"
+ },
+ "PrefetchCount": 10,
+ "AutomaticRecoveryEnabled": true
+ }
+}
+```
+
+**Environment Variables** (Production):
+```bash
+RabbitMQ__HostName=rabbitmq.prod.example.com
+RabbitMQ__UserName=unity-prod
+RabbitMQ__Password=***
+RabbitMQ__UseSsl=true
+```
+
+#### Inbox/Outbox Configuration
+
+**Applicant Portal Outbox Table**:
+```sql
+CREATE TABLE "OutboxMessages" (
+ "Id" UUID PRIMARY KEY,
+ "MessageId" UUID UNIQUE NOT NULL,
+ "MessageType" VARCHAR NOT NULL,
+ "Payload" JSONB NOT NULL,
+ "RoutingKey" VARCHAR NOT NULL,
+ "CreatedAt" TIMESTAMP NOT NULL,
+ "SentAt" TIMESTAMP NULL,
+ "Status" VARCHAR NOT NULL, -- Pending, Sent, Failed
+ "RetryCount" INT DEFAULT 0,
+ "ErrorMessage" TEXT NULL
+);
+
+CREATE INDEX "IX_OutboxMessages_Status_CreatedAt"
+ ON "OutboxMessages" ("Status", "CreatedAt");
+```
+
+**Applicant Portal Inbox Table**:
+```sql
+CREATE TABLE "InboxMessages" (
+ "Id" UUID PRIMARY KEY,
+ "MessageId" UUID UNIQUE NOT NULL,
+ "MessageType" VARCHAR NOT NULL,
+ "Payload" JSONB NOT NULL,
+ "ReceivedAt" TIMESTAMP NOT NULL,
+ "ProcessedAt" TIMESTAMP NULL,
+ "Status" VARCHAR NOT NULL, -- Pending, Processed, Failed
+ "RetryCount" INT DEFAULT 0,
+ "ErrorMessage" TEXT NULL
+);
+
+CREATE INDEX "IX_InboxMessages_Status_ReceivedAt"
+ ON "InboxMessages" ("Status", "ReceivedAt");
+```
+
+**Background Worker Configuration**:
+```json
+{
+ "BackgroundJobs": {
+ "OutboxPublisher": {
+ "IntervalSeconds": 5,
+ "BatchSize": 50,
+ "Enabled": true
+ },
+ "InboxProcessor": {
+ "IntervalSeconds": 5,
+ "BatchSize": 50,
+ "Enabled": true
+ }
+ }
+}
+```
+
+---
+
+### Monitoring & Observability
+
+#### Metrics to Track
+
+**Message Throughput**:
+- Messages published per minute
+- Messages consumed per minute
+- Average message processing time
+- Message lag (time in queue)
+
+**Queue Health**:
+- Queue depth (messages waiting)
+- Dead letter queue depth
+- Consumer count
+- Unacknowledged messages
+
+**Error Rates**:
+- Failed message percentage
+- Retry counts
+- DLQ message rate
+- Timeout rate
+
+**Inbox/Outbox**:
+- Pending outbox messages count
+- Pending inbox messages count
+- Average outbox processing delay
+- Failed message counts by type
+
+#### Logging
+
+**Structured Logging Example**:
+```csharp
+_logger.LogInformation(
+ "Processing message. MessageId: {MessageId}, Type: {MessageType}, CorrelationId: {CorrelationId}",
+ message.MessageId,
+ message.MessageType,
+ message.CorrelationId);
+
+_logger.LogError(ex,
+ "Failed to process message. MessageId: {MessageId}, Type: {MessageType}, RetryCount: {RetryCount}",
+ message.MessageId,
+ message.MessageType,
+ retryCount);
+```
+
+#### Alerts
+
+Configure alerts for:
+- DLQ depth > 10 messages
+- Queue depth > 1000 messages
+- Message processing failures > 5%
+- Outbox processing delay > 5 minutes
+- Consumer disconnections
+
+---
+
+### Testing
+
+#### Local Development
+
+**Docker Compose for RabbitMQ**:
+```yaml
+version: '3.8'
+services:
+ rabbitmq:
+ image: rabbitmq:3-management
+ ports:
+ - "5672:5672"
+ - "15672:15672"
+ environment:
+ RABBITMQ_DEFAULT_USER: admin
+ RABBITMQ_DEFAULT_PASS: admin
+ volumes:
+ - rabbitmq-data:/var/lib/rabbitmq
+
+volumes:
+ rabbitmq-data:
+```
+
+**Start RabbitMQ**:
+```bash
+docker-compose up -d rabbitmq
+```
+
+**Access Management UI**:
+```
+http://localhost:15672
+Username: admin
+Password: admin
+```
+
+#### Integration Testing
+
+**Test Scenarios**:
+1. ? Command sent from Portal reaches Unity
+2. ? Event published by Unity reaches Portal
+3. ? Duplicate messages handled idempotently
+4. ? Failed messages retry with backoff
+5. ? Permanent failures move to DLQ
+6. ? Outbox processes pending messages
+7. ? Inbox processes received messages
+8. ? Connection recovery after broker restart
+
+**Example Test**:
+```csharp
+[Fact]
+public async Task ApplicationStatusChanged_Event_ProcessedSuccessfully()
+{
+ // Arrange
+ var @event = new ApplicationStatusChangedEvent
+ {
+ MessageId = Guid.NewGuid(),
+ ApplicationId = _testApplicationId,
+ CurrentStatus = "Approved"
+ };
+
+ // Act - Publish to queue
+ await _messageBus.PublishAsync(@event);
+
+ // Wait for processing
+ await Task.Delay(2000);
+
+ // Assert - Check inbox
+ var inboxMessage = await _inboxRepository.FindByMessageIdAsync(@event.MessageId);
+ Assert.NotNull(inboxMessage);
+ Assert.Equal("Processed", inboxMessage.Status);
+
+ // Assert - Check business logic applied
+ var notification = await _notificationRepository.FindByApplicationIdAsync(_testApplicationId);
+ Assert.NotNull(notification);
+ Assert.Contains("Approved", notification.Message);
+}
+```
+
+---
+
+### Security
+
+#### Authentication
+
+**Connection Credentials**:
+- Use service accounts with limited permissions
+- Rotate credentials regularly (every 90 days)
+- Store in Azure Key Vault or equivalent
+
+**Queue Permissions**:
+```
+unity-service account:
+ - Read/Write: applicant-portal.commands
+ - Read/Write: unity.events.*
+
+applicant-portal account:
+ - Write: applicant-portal.commands
+ - Read: unity.events.*
+```
+
+#### Message Encryption
+
+**Transport Security**:
+- TLS 1.2+ for all connections
+- Certificate validation enabled
+
+**Message Payload Encryption** (Future):
+```csharp
+public class EncryptedMessage
+{
+ public string EncryptedPayload { get; set; } // AES-256 encrypted
+ public string EncryptionKeyId { get; set; } // Key vault reference
+ public string InitializationVector { get; set; }
+}
+```
+
+#### Sensitive Data Handling
+
+**Do NOT include in messages**:
+- Full credit card numbers
+- Social insurance numbers
+- Passwords or API keys
+- Unencrypted PII
+
+**DO include references**:
+- Entity IDs (GUIDs)
+- Reference numbers
+- Status codes
+
+---
+
+### Future Enhancements
+
+#### Planned Message Types
+
+**Commands** (Portal ? Unity):
+- `CreateApplicationDraftCommand`
+- `UploadDocumentCommand`
+- `WithdrawApplicationCommand`
+- `RequestApplicationReviewCommand`
+
+**Events** (Unity ? Portal):
+- `ReviewerAssignedEvent`
+- `AssessmentCompletedEvent`
+- `FundingAgreementGeneratedEvent`
+- `PaymentProcessedEvent`
+- `DocumentReceivedEvent`
+
+#### Saga Pattern
+
+For complex workflows spanning both systems:
+```mermaid
+sequenceDiagram
+ participant Portal as Applicant Portal
+ participant Unity as Unity Grant Manager
+
+ Portal->>Unity: InitiateApplicationSubmission
+ Unity->>Unity: ValidateApplicationCommand
+ Unity->>Portal: ApplicationValidatedEvent
+ Portal->>Unity: ConfirmSubmissionCommand
+ Unity->>Portal: ApplicationSubmittedEvent
+ Portal->>Portal: NotifyApplicantCommand
+
+ Note over Portal,Unity: Application Submission Saga
+```
+
+#### Event Sourcing
+
+Consider event sourcing for audit trail:
+- All state changes captured as events
+- Complete history replay capability
+- Temporal queries (state at any point in time)
+
+---
+
+### Troubleshooting
+
+#### Issue: Messages Not Being Consumed
+
+**Symptoms**: Queue depth increasing, messages not processed
+
+**Possible Causes**:
+1. Consumer not running
+2. Connection issues
+3. Handler exceptions
+4. Prefetch limit reached
+
+**Resolution**:
+```bash
+# Check RabbitMQ management UI
+http://localhost:15672
+
+# Check consumer status
+# Verify consumer count > 0 on queue
+
+# Check application logs
+grep "RabbitMQ consumer" application.log
+
+# Test connection
+telnet rabbitmq-host 5672
+```
+
+#### Issue: Messages in Dead Letter Queue
+
+**Symptoms**: DLQ depth > 0
+
+**Resolution**:
+1. Inspect message in RabbitMQ UI
+2. Check error logs for message ID
+3. Fix underlying issue (code bug, missing data)
+4. Manually reprocess or discard message
+
+**Reprocess from DLQ**:
+```csharp
+// Admin endpoint to reprocess DLQ messages
+[HttpPost("admin/reprocess-dlq")]
+public async Task ReprocessDeadLetterQueue([FromQuery] string queueName)
+{
+ var messages = await _deadLetterService.GetMessagesAsync(queueName);
+ foreach (var message in messages)
+ {
+ await _messageBus.RepublishAsync(message);
+ }
+ return Ok($"Reprocessed {messages.Count} messages");
+}
+```
+
+#### Issue: Slow Message Processing
+
+**Symptoms**: High message lag, slow throughput
+
+**Possible Causes**:
+1. Insufficient consumers
+2. Slow handler logic
+3. Database bottleneck
+4. Network latency
+
+**Resolution**:
+- Increase prefetch count
+- Add more consumer instances
+- Optimize handler logic
+- Add database indexes
+- Profile slow operations
+
+---
+
+### Support
+
+**RabbitMQ Infrastructure**:
+- DevOps Team
+- Infrastructure repository
+
+**Message Integration Issues**:
+- Unity Grant Manager team
+- Applicant Portal team
+- Integration Slack channel
+
+---
+
+## Error Handling
+
+
+
+### HTTP Status Codes
+
+| Status Code | Description | Example Scenario |
+|-------------|-------------|------------------|
+| 200 OK | Successful request | Valid query returns results |
+| 400 Bad Request | Invalid parameters | Missing required query parameter |
+| 401 Unauthorized | Missing or invalid API key | Wrong API key provided |
+| 404 Not Found | Resource not found | Profile doesn't exist |
+| 500 Internal Server Error | Server-side error | Database connection failure |
+
+### Error Response Format
+
+The API returns [RFC 7807 Problem Details](https://tools.ietf.org/html/rfc7807) responses:
+
+```json
+{
+ "type": "string (URI reference)",
+ "title": "string",
+ "status": 0,
+ "detail": "string"
+}
+```
+
+### Common Error Scenarios
+
+**1. Missing API Key**:
+```http
+HTTP/1.1 401 Unauthorized
+
+{
+ "type": "https://tools.ietf.org/html/rfc7235#section-3.1",
+ "title": "Unauthorized",
+ "status": 401,
+ "detail": "API Key missing"
+}
+```
+
+**2. Invalid API Key**:
+```http
+HTTP/1.1 401 Unauthorized
+
+{
+ "type": "https://tools.ietf.org/html/rfc7235#section-3.1",
+ "title": "Unauthorized",
+ "status": 401,
+ "detail": "Invalid API Key"
+}
+```
+
+**3. API Key Not Configured on Server**:
+```http
+HTTP/1.1 401 Unauthorized
+
+{
+ "type": "https://tools.ietf.org/html/rfc7235#section-3.1",
+ "title": "Unauthorized",
+ "status": 401,
+ "detail": "API Key not configured"
+}
+```
+
+**4. Invalid Subject Format**:
+```http
+HTTP/1.1 400 Bad Request
+
+{
+ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
+ "title": "Bad Request",
+ "status": 400,
+ "detail": "Subject parameter is required"
+}
+```
+
+**3. No Tenants Found**:
+```http
+HTTP/1.1 200 OK
+
+[]
+```
+*Note: Returns empty array, not an error*
+
+---
+
+## Performance Considerations
+
+### Query Performance
+
+**Lookup Time**: < 50ms (typically < 10ms)
+- Direct index lookup on `OidcSubUsername`
+- Single database query to Host DB
+- No multi-tenant context switching
+
+**Scalability**:
+- Supports millions of mappings
+- Performance doesn't degrade with tenant count
+- Read-heavy workload optimized
+
+### Caching Strategy
+
+**Why Database Over Redis**:
+1. **No Cold Start**: No expensive multi-tenant scan on cache miss
+2. **Durability**: Data persists across restarts
+3. **Simplicity**: Single source of truth, no cache invalidation
+4. **Cost**: No Redis infrastructure required
+
+### Monitoring
+
+**Metrics to Track**:
+- API request latency (target: < 100ms p95)
+- API request rate per minute
+- Error rate by status code
+- Background job execution time
+- Mapping creation/update counts
+
+**Alerts**:
+- API error rate > 5%
+- API latency > 500ms
+- Background job failures
+- Missing mappings detected
+
+---
+
+## Testing
+
+### Manual Testing
+
+**1. Test Tenant Lookup**:
+```bash
+curl -X GET "https://unity-dev.example.com/api/app/applicant-profiles/tenants?ProfileId=3fa85f64-5717-4562-b3fc-2c963f66afa6&Subject=TESTUSER@idp" \
+ -H "X-Api-Key: your-dev-api-key"
+```
+
+**Expected Response**:
+```json
+[
+ {
+ "tenantId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
+ "tenantName": "Test Tenant"
+ }
+]
+```
+
+**2. Test Profile Retrieval**:
+```bash
+curl -X GET "https://unity-dev.example.com/api/app/applicant-profiles/profile?ProfileId=3fa85f64-5717-4562-b3fc-2c963f66afa6&Subject=TESTUSER@idp&TenantId=7c9e6679-7425-40de-944b-e07fc1f90ae7" \
+ -H "X-Api-Key: your-dev-api-key"
+```
+
+**3. Test Invalid API Key**:
+```bash
+curl -X GET "https://unity-dev.example.com/api/app/applicant-profiles/tenants?ProfileId=3fa85f64-5717-4562-b3fc-2c963f66afa6&Subject=TESTUSER@idp" \
+ -H "X-Api-Key: invalid-key"
+```
+
+**Expected Response**: 401 Unauthorized
+
+### Integration Testing
+
+**Test Scenarios**:
+1. ? User with submissions in multiple tenants returns all tenants
+2. ? User with no submissions returns empty array
+3. ? New submission creates mapping immediately
+4. ? Duplicate submissions update LastUpdated timestamp
+5. ? Background job creates missing mappings
+6. ? API key authentication blocks unauthorized requests
+7. ? OIDC sub normalization handles various formats
+
+### Database Verification
+
+**Check Mapping Exists**:
+```sql
+SELECT *
+FROM "AppApplicantTenantMaps"
+WHERE "OidcSubUsername" = 'TESTUSER';
+```
+
+**Check Mapping Count by Tenant**:
+```sql
+SELECT "TenantName", COUNT(*)
+FROM "AppApplicantTenantMaps"
+GROUP BY "TenantName";
+```
+
+**Find Recent Updates**:
+```sql
+SELECT *
+FROM "AppApplicantTenantMaps"
+WHERE "LastUpdated" > NOW() - INTERVAL '1 hour'
+ORDER BY "LastUpdated" DESC;
+```
+
+---
+
+## Deployment
+
+### Initial Data Migration
+
+After deploying this feature, populate existing data:
+
+**Option 1: Call Service Method** (Recommended):
+```csharp
+var (created, updated) = await applicantProfileAppService.ReconcileApplicantTenantMapsAsync();
+Logger.LogInformation("Initial reconciliation: Created {Created}, Updated {Updated}", created, updated);
+```
+
+**Option 2: Trigger Background Job Immediately**:
+```csharp
+// Run job on-demand (e.g., via admin endpoint)
+await applicantTenantMapReconciliationWorker.Execute(jobExecutionContext);
+```
+
+**Option 3: Wait for Scheduled Run**:
+- Background job will run at next scheduled time (2 AM PST)
+- May take up to 24 hours for initial population
+
+### Database Migration
+
+**Required Migration**:
+```bash
+cd src/Unity.GrantManager.EntityFrameworkCore
+dotnet ef migrations add AddApplicantTenantMaps
+dotnet ef database update
+```
+
+**Verify Migration**:
+```bash
+dotnet ef migrations list
+```
+
+### Configuration Checklist
+
+- [ ] `B2BAuth:ApiKey` configured in production secrets
+- [ ] Background job cron expression verified (2 AM PST = 10 AM UTC)
+- [ ] Database migration applied to Host database
+- [ ] Initial data populated (via reconciliation)
+- [ ] API endpoints tested with valid API key
+- [ ] Monitoring/alerts configured
+- [ ] Documentation provided to Applicant Portal team
+
+---
+
+## Troubleshooting
+
+### Issue: No Tenants Returned
+
+**Symptoms**: API returns empty array `[]`
+
+**Possible Causes**:
+1. User has never submitted applications
+2. Mapping hasn't been created yet
+3. OIDC subject normalization mismatch
+
+**Resolution**:
+```sql
+-- Check if mapping exists
+SELECT * FROM "AppApplicantTenantMaps" WHERE "OidcSubUsername" LIKE '%useridentifier%';
+
+-- Check submissions in tenant database
+SELECT "OidcSub" FROM "AppApplicationFormSubmissions" WHERE "OidcSub" LIKE '%useridentifier%';
+
+-- Manually trigger reconciliation
+-- (via admin endpoint or background job)
+```
+
+### Issue: 401 Unauthorized
+
+**Symptoms**: API returns 401 error
+
+**Possible Causes**:
+1. Missing `X-Api-Key` header
+2. Invalid API key value
+3. `B2BAuth:ApiKey` not configured on server
+
+**Resolution**:
+- Verify header is included: `X-Api-Key: your-key`
+- Check server configuration has `B2BAuth:ApiKey` set correctly
+- Review logs for authentication failures
+
+### Issue: Background Job Not Running
+
+**Symptoms**: No log entries for reconciliation
+
+**Possible Causes**:
+1. Cron expression misconfigured
+2. Background job not registered
+3. Quartz scheduler issue
+
+**Resolution**:
+```csharp
+// Verify cron expression
+// Expected: "0 0 10 1/1 * ? *" (2 AM PST)
+
+// Check Quartz logs
+Logger.LogInformation("ApplicantTenantMapReconciliationWorker scheduled with: {Cron}", cronExpression);
+
+// Test immediately
+await applicantProfileAppService.ReconcileApplicantTenantMapsAsync();
+```
+
+### Issue: Slow API Response
+
+**Symptoms**: Requests take > 500ms
+
+**Possible Causes**:
+1. Missing database index
+2. Large result set (many tenants)
+3. Database connection issues
+
+**Resolution**:
+```sql
+-- Verify index exists
+SELECT * FROM pg_indexes WHERE tablename = 'AppApplicantTenantMaps';
+
+-- Check query performance
+EXPLAIN ANALYZE
+SELECT * FROM "AppApplicantTenantMaps" WHERE "OidcSubUsername" = 'TESTUSER';
+
+-- Verify connection pooling
+-- Check database connection string settings
+```
+
+---
+
+## Support
+
+For issues or questions:
+- **Internal**: Contact Unity Grant Manager team
+- **Repository**: https://github.com/bcgov/Unity
+- **Documentation**: This file and related docs in `/docs` folder
+
+---
+
+## Related Documentation
+
+- [Applicant Tenant Mapping Implementation](./ApplicantTenantMapping.md) - Technical implementation 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
+
+---
+
+## Changelog
+
+| Date | Version | Changes |
+|------|---------|---------|
+| 2026-01-XX | 1.0.0 | Initial documentation |
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs
index f9ab61c36..be14623c4 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Exceptions/ErrorConsts.cs
@@ -8,5 +8,6 @@ public static class ErrorConsts
public const string ConfigurationDoesNotExist = "Unity.Payments:Errors:ConfigurationDoesNotExist";
public const string InvalidAccountCodingField = "Unity.Payments:Errors:InvalidAccountCodingFiled";
public const string L2ApproverRestriction = "Unity.Payments:Errors:L2ApproverRestriction";
+ public const string MissingSupplierNumber = "Unity.Payments:Errors:MissingSupplierNumber";
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs
index 91edb85a6..629eab4a6 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs
@@ -205,6 +205,10 @@ public PaymentRequest ValidatePaymentRequest()
throw new BusinessException(ErrorConsts.ZeroPayment);
}
+ if (string.IsNullOrWhiteSpace(SupplierNumber))
+ {
+ throw new BusinessException(ErrorConsts.MissingSupplierNumber);
+ }
return this;
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs
index 489790435..a6dccf31e 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/SupplierService.cs
@@ -47,18 +47,18 @@ private static async Task InitializeBaseApiAsync(IEndpointManagementAppS
return url ?? throw new UserFriendlyException("Payment API base URL is not configured.");
}
- public virtual async Task UpdateApplicantSupplierInfo(string? supplierNumber, Guid applicantId, Guid? applicationId = null)
- {
- Logger.LogInformation("SupplierService->UpdateApplicantSupplierInfo: {SupplierNumber}, {ApplicantId}, {ApplicationId}", supplierNumber, applicantId, applicationId);
-
- // Integrate with payments module to update / insert supplier
- if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature)
- && !string.IsNullOrEmpty(supplierNumber))
- {
- dynamic casSupplierResponse = await GetCasSupplierInformationAsync(supplierNumber);
- await UpdateSupplierInfo(casSupplierResponse, applicantId, applicationId);
- }
- }
+ public virtual async Task UpdateApplicantSupplierInfo(string? supplierNumber, Guid applicantId, Guid? applicationId = null)
+ {
+ Logger.LogInformation("SupplierService->UpdateApplicantSupplierInfo: {SupplierNumber}, {ApplicantId}, {ApplicationId}", supplierNumber, applicantId, applicationId);
+
+ // Integrate with payments module to update / insert supplier
+ if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature)
+ && !string.IsNullOrEmpty(supplierNumber))
+ {
+ dynamic casSupplierResponse = await GetCasSupplierInformationAsync(supplierNumber);
+ await UpdateSupplierInfo(casSupplierResponse, applicantId, applicationId);
+ }
+ }
public async Task UpdateApplicantSupplierInfoByBn9(string? bn9, Guid applicantId)
{
@@ -92,21 +92,25 @@ public async Task UpdateApplicantSupplierInfoByBn9(string? bn9, Guid ap
return casSupplierResponse;
}
- public async Task UpdateSupplierInfo(dynamic casSupplierResponse, Guid applicantId, Guid? applicationId = null)
- {
- try
- {
- var casSupplierJson = casSupplierResponse is string str ? str : casSupplierResponse.ToString();
- using var doc = JsonDocument.Parse(casSupplierJson);
+ public async Task UpdateSupplierInfo(dynamic casSupplierResponse, Guid applicantId, Guid? applicationId = null)
+ {
+ try
+ {
+ var casSupplierJson = casSupplierResponse is string str ? str : casSupplierResponse.ToString();
+ using var doc = JsonDocument.Parse(casSupplierJson);
var rootElement = doc.RootElement;
if (rootElement.TryGetProperty("code", out JsonElement codeProp) && codeProp.GetString() == "Unauthorized")
throw new UserFriendlyException("Unauthorized access to CAS supplier information.");
- UpsertSupplierEto supplierEto = GetEventDtoFromCasResponse(rootElement);
- supplierEto.CorrelationId = applicantId;
- supplierEto.CorrelationProvider = CorrelationConsts.Applicant;
- supplierEto.ApplicationId = applicationId;
- await localEventBus.PublishAsync(supplierEto);
- }
+ UpsertSupplierEto supplierEto = GetEventDtoFromCasResponse(rootElement);
+ supplierEto.CorrelationId = applicantId;
+ supplierEto.CorrelationProvider = CorrelationConsts.Applicant;
+ supplierEto.ApplicationId = applicationId;
+ await localEventBus.PublishAsync(supplierEto);
+ }
+ catch (UserFriendlyException)
+ {
+ throw;
+ }
catch (Exception ex)
{
Logger.LogError(ex, "An exception occurred updating the supplier: {ExceptionMessage}", ex.Message);
@@ -123,6 +127,13 @@ string GetProp(string name) =>
string lastUpdated = GetProp("lastupdated");
string suppliernumber = GetProp("suppliernumber");
+
+ if (string.IsNullOrWhiteSpace(suppliernumber))
+ {
+ throw new UserFriendlyException(
+ "CAS integration returned an empty Supplier Number. Please verify the supplier information in CAS.");
+ }
+
string suppliername = GetProp("suppliername");
string subcategory = GetProp("subcategory");
string providerid = GetProp("providerid");
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs
index d173f9b2f..49ec8b4b0 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs
@@ -15,7 +15,7 @@ public interface ISupplierAppService : IApplicationService
Task CreateSiteAsync(Guid id, CreateSiteDto createSiteDto);
Task UpdateSiteAsync(Guid id, Guid siteId, UpdateSiteDto updateSiteDto);
Task GetSitesBySupplierNumberAsync(string? supplierNumber, Guid applicantId, Guid? applicationId = null);
- Task DeleteAsync(Guid id);
SiteDto GetSiteDtoFromSiteEto(SiteEto siteEto, Guid supplierId, PaymentGroup? defaultPaymentGroup = null);
+ Task ClearCorrelationAsync(Guid supplierId);
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs
index 9b6b1bf2c..cddee30a7 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs
@@ -252,11 +252,6 @@ public virtual async Task UpdateSiteAsync(Guid id, Guid siteId, UpdateS
return ObjectMapper.Map(updateSupplier.Sites.First(s => s.Id == siteId));
}
- public virtual async Task DeleteAsync(Guid id)
- {
- await supplierRepository.DeleteAsync(id);
- }
-
public SiteDto GetSiteDtoFromSiteEto(SiteEto siteEto, Guid supplierId, PaymentGroup? defaultPaymentGroup = null)
{
var resolvedPaymentGroup = defaultPaymentGroup ?? PaymentGroup.EFT;
@@ -282,6 +277,14 @@ public SiteDto GetSiteDtoFromSiteEto(SiteEto siteEto, Guid supplierId, PaymentGr
};
}
+ public async Task ClearCorrelationAsync(Guid supplierId)
+ {
+ var supplier = await supplierRepository.GetAsync(supplierId);
+ supplier.CorrelationId = Guid.Empty;
+ supplier.CorrelationProvider = string.Empty;
+ await supplierRepository.UpdateAsync(supplier);
+ }
+
private async Task ResolveDefaultPaymentGroupForApplicantAsync(Guid applicantId, Guid? applicationId = null)
{
const PaymentGroup fallbackPaymentGroup = PaymentGroup.EFT;
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json
index 7e9985a4e..0a18cb85e 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json
@@ -6,6 +6,7 @@
"Unity.Payments:Errors:ThresholdExceeded": "There are payments in this batch that require a third level of approval. Please remove them from this batch and add to another for the appropriate level of approval",
"Unity.Payments:Errors:ZeroPayment": "Cannot submit a payment request for $0.00",
+ "Unity.Payments:Errors:MissingSupplierNumber": "Cannot submit a payment request without a supplier number",
"Unity.Payments:Errors:ConfigurationExists": "Configuration already exitst",
"Unity.Payments:Errors:ConfigurationDoesNotExist": "Configuration does not exits",
"Unity.Payments:Errors:InvalidAccountCodingFiled": "Invalid account coding field {field} : {length}",
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs
index d54ee06cf..caf1aee4a 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequests.cshtml.cs
@@ -160,7 +160,7 @@ public async Task OnGetAsync(string cacheKey)
bool missingFields = false;
List errorList = [];
- if (supplier == null || site == null || supplier.Number == null)
+ if (supplier == null || site == null || string.IsNullOrWhiteSpace(supplier.Number))
{
missingFields = true;
}
@@ -178,7 +178,7 @@ public async Task OnGetAsync(string cacheKey)
if (missingFields)
{
- errorList.Add("Some payment information is missing for this applicant, please make sure Supplier info is provided and default site is selected.");
+ errorList.Add("Some payment information is missing for this applicant. Please make sure supplier information is provided and default site is selected.");
}
if (application.StatusCode != GrantApplicationState.GRANT_APPROVED)
@@ -308,6 +308,12 @@ public async Task OnPostAsync()
throw new UserFriendlyException(string.Join(" ", validationErrors));
}
+ if (ApplicationPaymentRequestForm.Exists(payment => string.IsNullOrWhiteSpace(payment.SupplierNumber)))
+ {
+ throw new UserFriendlyException(
+ "Cannot submit payment request: Supplier number is missing for one or more applications.");
+ }
+
var payments = MapPaymentRequests();
await paymentRequestAppService.CreateAsync(payments);
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs
index 162fa4f7d..6fd509b3e 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs
@@ -70,10 +70,10 @@ public async Task CreateAsync_CreatesPaymentRequest()
Description = "",
PayeeName= "",
SiteId= siteId,
- SupplierNumber = "",
+ SupplierNumber = "SUP-TEST",
}
];
- // Act
+ // Act
var insertedPaymentRequest = await _paymentRequestAppService
.CreateAsync(paymentRequests);
@@ -97,7 +97,7 @@ public async Task GetListAsync_ReturnsPaymentsList()
Amount = 100,
PayeeName = "Test",
ContractNumber = "0000000000",
- SupplierNumber = "",
+ SupplierNumber = "SUP-TEST",
SiteId = addedSupplier.Sites[0].Id,
CorrelationId = Guid.NewGuid(),
CorrelationProvider = "",
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj
index af8be1dee..92019f78c 100644
--- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Unity.AspNetCore.Mvc.UI.Theme.UX2.csproj
@@ -26,7 +26,7 @@
-
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs
new file mode 100644
index 000000000..30e2c1335
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileDto.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace Unity.GrantManager.Applicants
+{
+ public class ApplicantProfileDto
+ {
+ public Guid ProfileId { get; set; }
+ public string Subject { get; set; } = string.Empty;
+ public string Email { get; set; } = string.Empty;
+ public string DisplayName { get; set; } = string.Empty;
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs
new file mode 100644
index 000000000..3e5e2b610
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantProfileRequest.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Unity.GrantManager.Applicants
+{
+ public class ApplicantProfileRequest
+ {
+ public Guid ProfileId { get; set; } = Guid.NewGuid();
+ public string Subject { get; set; } = string.Empty;
+ }
+
+ public class TenantedApplicantProfileRequest : ApplicantProfileRequest
+ {
+ public Guid TenantId { get; set; }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs
new file mode 100644
index 000000000..9794ad422
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantTenantDto.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace Unity.GrantManager.Applicants
+{
+ public class ApplicantTenantDto
+ {
+ public Guid TenantId { get; set; }
+ public string TenantName { get; set; } = string.Empty;
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs
new file mode 100644
index 000000000..bab1f950c
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/IApplicantProfileAppService.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Unity.GrantManager.Applicants
+{
+ public interface IApplicantProfileAppService
+ {
+ Task GetApplicantProfileAsync(ApplicantProfileRequest request);
+ Task> GetApplicantTenantsAsync(ApplicantProfileRequest request);
+ Task<(int Created, int Updated)> ReconcileApplicantTenantMapsAsync();
+ }
+}
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs
index e90e8ebe6..31a35a873 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationDto.cs
@@ -19,7 +19,7 @@ public class GrantApplicationDto : AuditedEntityDto
public string Status { get; set; } = string.Empty;
public int Probability { get; set; }
public DateTime ProposalDate { get; set; }
-
+ public List ApplicationLinks { get; set; } = new();
public string ApplicationName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string EconomicRegion { get; set; } = string.Empty;
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs
new file mode 100644
index 000000000..7f7066a0f
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantProfileAppService.cs
@@ -0,0 +1,176 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Unity.GrantManager.Applications;
+using Volo.Abp;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.TenantManagement;
+
+namespace Unity.GrantManager.Applicants
+{
+ [RemoteService(false)]
+ public class ApplicantProfileAppService(
+ ICurrentTenant currentTenant,
+ ITenantRepository tenantRepository,
+ IRepository applicantTenantMapRepository,
+ IRepository applicationFormSubmissionRepository)
+ : ApplicationService, IApplicantProfileAppService
+ {
+
+ ///
+ /// Retrieves the applicant's profile information based on the specified request.
+ ///
+ /// An object containing the criteria used to identify the applicant profile to retrieve. Must not be null.
+ /// A task that represents the asynchronous operation. The task result contains an with the applicant's profile data.
+ public async Task GetApplicantProfileAsync(ApplicantProfileRequest request)
+ {
+ return await Task.FromResult(new ApplicantProfileDto
+ {
+ ProfileId = request.ProfileId,
+ Subject = request.Subject,
+ Email = string.Empty,
+ DisplayName = string.Empty
+ });
+ }
+
+ ///
+ /// Retrieves a list of tenants associated with the specified applicant profile.
+ ///
+ /// The method extracts the username portion from the subject identifier in the request
+ /// to match tenant mappings. This operation is asynchronous and queries the host database for relevant tenant
+ /// associations.
+ /// An object containing applicant profile information, including the subject identifier used to locate tenant
+ /// mappings.
+ /// A list of objects representing the tenants linked to the applicant. The
+ /// list will be empty if no tenant associations are found.
+ public async Task> GetApplicantTenantsAsync(ApplicantProfileRequest request)
+ {
+ // Extract the username part from the OIDC sub (part before '@')
+ var subUsername = request.Subject.Contains('@')
+ ? request.Subject[..request.Subject.IndexOf('@')].ToUpperInvariant()
+ : request.Subject.ToUpperInvariant();
+
+ // Query the ApplicantTenantMaps table in the host database
+ using (currentTenant.Change(null))
+ {
+ var queryable = await applicantTenantMapRepository.GetQueryableAsync();
+ var mappings = await queryable
+ .Where(m => m.OidcSubUsername == subUsername)
+ .Select(m => new ApplicantTenantDto
+ {
+ TenantId = m.TenantId,
+ TenantName = m.TenantName
+ })
+ .ToListAsync();
+
+ return mappings;
+ }
+ }
+
+ ///
+ /// Reconciles ApplicantTenantMaps by scanning all tenants for submissions
+ /// and ensuring mappings exist in the host database.
+ /// Phase 1: Collects all distinct OidcSub-to-tenant associations into memory.
+ /// Phase 2: Switches to host DB once and reconciles all mappings.
+ ///
+ /// Tuple of (created count, updated count)
+ public async Task<(int Created, int Updated)> ReconcileApplicantTenantMapsAsync()
+ {
+ Logger.LogInformation("Starting ApplicantTenantMap reconciliation...");
+
+ // Phase 1: Collect all OidcSub-to-tenant associations from each tenant DB
+ var desiredMappings = new List<(string SubUsername, Guid TenantId, string TenantName)>();
+ var tenants = await tenantRepository.GetListAsync();
+
+ foreach (var tenant in tenants)
+ {
+ try
+ {
+ Logger.LogDebug("Collecting submissions from tenant: {TenantName}", tenant.Name);
+
+ using (currentTenant.Change(tenant.Id))
+ {
+ var submissionQueryable = await applicationFormSubmissionRepository.GetQueryableAsync();
+ var distinctOidcSubs = await submissionQueryable
+ .Where(s => !string.IsNullOrWhiteSpace(s.OidcSub) && s.OidcSub != Guid.Empty.ToString())
+ .Select(s => s.OidcSub)
+ .Distinct()
+ .ToListAsync();
+
+ foreach (var oidcSub in distinctOidcSubs)
+ {
+ var subUsername = oidcSub.Contains('@')
+ ? oidcSub[..oidcSub.IndexOf('@')].ToUpperInvariant()
+ : oidcSub.ToUpperInvariant();
+
+ desiredMappings.Add((subUsername, tenant.Id, tenant.Name));
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex, "Error collecting submissions for tenant {TenantName}", tenant.Name);
+ }
+ }
+
+ if (desiredMappings.Count == 0)
+ {
+ Logger.LogInformation("ApplicantTenantMap reconciliation completed. No submissions found across tenants.");
+ return (0, 0);
+ }
+
+ // Phase 2: Switch to host DB once, load existing mappings, and reconcile
+ int totalMappingsCreated = 0;
+ int totalMappingsUpdated = 0;
+
+ using (currentTenant.Change(null))
+ {
+ var allSubUsernames = desiredMappings.Select(m => m.SubUsername).Distinct().ToList();
+
+ var mapQueryable = await applicantTenantMapRepository.GetQueryableAsync();
+ var existingMappings = await mapQueryable
+ .Where(m => allSubUsernames.Contains(m.OidcSubUsername))
+ .ToListAsync();
+
+ var existingByKey = existingMappings
+ .ToDictionary(m => (m.OidcSubUsername, m.TenantId));
+
+ foreach (var (subUsername, tenantId, tenantName) in desiredMappings)
+ {
+ if (existingByKey.TryGetValue((subUsername, tenantId), out var existing))
+ {
+ existing.LastUpdated = DateTime.UtcNow;
+ await applicantTenantMapRepository.UpdateAsync(existing);
+ totalMappingsUpdated++;
+ }
+ else
+ {
+ var newMapping = new ApplicantTenantMap
+ {
+ OidcSubUsername = subUsername,
+ TenantId = tenantId,
+ TenantName = tenantName,
+ LastUpdated = DateTime.UtcNow
+ };
+ await applicantTenantMapRepository.InsertAsync(newMapping);
+ existingByKey[(subUsername, tenantId)] = newMapping;
+ totalMappingsCreated++;
+ Logger.LogInformation("Created missing ApplicantTenantMap for {SubUsername} in tenant {TenantName}",
+ subUsername, tenantName);
+ }
+ }
+ }
+
+ Logger.LogInformation("ApplicantTenantMap reconciliation completed. Created: {Created}, Updated: {Updated}",
+ totalMappingsCreated, totalMappingsUpdated);
+
+ return (totalMappingsCreated, totalMappingsUpdated);
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs
index c29c34ed6..3e2f37b36 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantSupplierAppService.cs
@@ -43,11 +43,11 @@ public async Task GetSupplierByBusinessNumber(string bn9)
/// Update the supplier number for the applicant regardless of application.
///
[Authorize(UnitySelector.Payment.Supplier.Update)]
- public async Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string supplierNumber, Guid? applicationId = null)
- {
- if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature))
- {
- await applicantRepository.EnsureExistsAsync(applicantId);
+ public async Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string supplierNumber, Guid? applicationId = null)
+ {
+ if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature))
+ {
+ await applicantRepository.EnsureExistsAsync(applicantId);
// Handle clearing supplier information
if (string.IsNullOrEmpty(supplierNumber))
@@ -58,14 +58,14 @@ public async Task UpdateApplicantSupplierNumberAsync(Guid applicantId, string su
var supplier = await GetSupplierByApplicantIdAsync(applicantId);
- if (supplier != null && string.Compare(supplierNumber, supplier?.Number, true) == 0)
- {
- return; // No change in supplier number, so no action needed
- }
-
- await supplierService.UpdateApplicantSupplierInfo(supplierNumber, applicantId, applicationId);
- }
- }
+ if (supplier != null && string.Compare(supplierNumber, supplier.Number, true) == 0)
+ {
+ return; // No change in supplier number, so no action needed
+ }
+
+ await supplierService.UpdateApplicantSupplierInfo(supplierNumber, applicantId, applicationId);
+ }
+ }
[Authorize(UnitySelector.Payment.Supplier.Update)]
public async Task ClearApplicantSupplierAsync(Guid applicantId)
@@ -75,17 +75,35 @@ public async Task ClearApplicantSupplierAsync(Guid applicantId)
await applicantRepository.EnsureExistsAsync(applicantId);
var applicant = await applicantRepository.GetAsync(applicantId);
- var supplierId = applicant.SupplierId; // Store the supplier ID before clearing
-
+ var supplierId = applicant.SupplierId;
+
// Clear the applicant references first
applicant.SupplierId = null;
applicant.SiteId = null;
await applicantRepository.UpdateAsync(applicant);
- if (supplierId.HasValue)
+ if (supplierId.HasValue)
{
- await supplierAppService.DeleteAsync(supplierId.Value);
+ await supplierAppService.ClearCorrelationAsync(supplierId.Value);
}
+ else
+ {
+ // Handle existing data where SupplierId was already cleared
+ // but the supplier's correlation was never removed
+ var supplier = await supplierAppService.GetByCorrelationAsync(
+ new GetSupplierByCorrelationDto()
+ {
+ CorrelationId = applicantId,
+ CorrelationProvider = CorrelationConsts.Applicant,
+ IncludeDetails = false
+ });
+
+ if (supplier != null)
+ {
+ await supplierAppService.ClearCorrelationAsync(supplier.Id);
+ }
+ }
+
}
}
@@ -123,4 +141,4 @@ public async Task DefaultApplicantSite(Guid applicantId, Guid siteId)
IncludeDetails = true
});
}
-}
+}
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
new file mode 100644
index 000000000..b93b81032
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/BackgroundWorkers/ApplicantTenantMapReconciliationWorker.cs
@@ -0,0 +1,105 @@
+using Microsoft.Extensions.Logging;
+using Quartz;
+using System;
+using System.Threading.Tasks;
+using Unity.GrantManager.Settings;
+using Unity.Modules.Shared.Utils;
+using Volo.Abp.BackgroundWorkers.Quartz;
+using Volo.Abp.SettingManagement;
+
+namespace Unity.GrantManager.Applicants.BackgroundWorkers
+{
+ [DisallowConcurrentExecution]
+ public class ApplicantTenantMapReconciliationWorker : QuartzBackgroundWorkerBase
+ {
+ private readonly IApplicantProfileAppService _applicantProfileAppService;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the ApplicantTenantMapReconciliationWorker class with the specified services
+ /// and logger.
+ ///
+ /// The scheduling behavior of the worker is determined by a cron expression retrieved
+ /// from application settings. If the setting is unavailable or cannot be read, a default schedule is used.
+ /// Logging is performed for any issues encountered during initialization.
+ /// The service used to access and manage applicant profile data.
+ /// The setting manager used to retrieve configuration values, including the cron expression for scheduling.
+ /// The logger used to record diagnostic and operational information for this worker.
+ public ApplicantTenantMapReconciliationWorker(
+ IApplicantProfileAppService applicantProfileAppService,
+ ISettingManager settingManager,
+ ILogger logger)
+ {
+ _applicantProfileAppService = applicantProfileAppService;
+ _logger = logger;
+
+ // 2 AM PST = 10 AM UTC
+ const string defaultCronExpression = "0 0 10 1/1 * ? *";
+ string cronExpression = defaultCronExpression;
+
+ try
+ {
+ var settingsValue = SettingDefinitions
+ .GetSettingsValue(settingManager,
+ SettingsConstants.BackgroundJobs.ApplicantTenantMapReconciliation_Expression);
+
+ if (!settingsValue.IsNullOrEmpty())
+ {
+ if (CronExpression.IsValidExpression(settingsValue))
+ {
+ cronExpression = settingsValue;
+ }
+ else
+ {
+ _logger.LogWarning("Invalid cron expression '{CronExpression}' for tenant map reconciliation, reverting to default '{DefaultCronExpression}'",
+ settingsValue, defaultCronExpression);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Error reading cron setting for tenant maps, reverting to default '{CronExpression}'", defaultCronExpression);
+ }
+
+ if (!cronExpression.IsNullOrEmpty())
+ {
+
+ JobDetail = JobBuilder
+ .Create()
+ .WithIdentity(nameof(ApplicantTenantMapReconciliationWorker))
+ .Build();
+
+ Trigger = TriggerBuilder
+ .Create()
+ .WithIdentity(nameof(ApplicantTenantMapReconciliationWorker))
+ .WithSchedule(CronScheduleBuilder.CronSchedule(cronExpression)
+ .WithMisfireHandlingInstructionIgnoreMisfires())
+ .Build();
+ }
+ }
+
+ ///
+ /// Executes the reconciliation process for applicant-tenant mappings as part of a scheduled job.
+ ///
+ /// This method is typically invoked by a job scheduler and should not be called directly
+ /// in application code. Logging is performed to record the outcome of the reconciliation process and any errors
+ /// encountered.
+ /// The execution context for the job, providing runtime information and job-specific data.
+ /// A task that represents the asynchronous execution of the job.
+ public override async Task Execute(IJobExecutionContext context)
+ {
+ _logger.LogInformation("Executing ApplicantTenantMapReconciliationWorker...");
+
+ try
+ {
+ var (created, updated) = await _applicantProfileAppService.ReconcileApplicantTenantMapsAsync();
+ _logger.LogInformation("ApplicantTenantMapReconciliationWorker completed. Created: {Created}, Updated: {Updated}",
+ created, updated);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in ApplicantTenantMapReconciliationWorker");
+ }
+ }
+ }
+}
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 741af8be1..4a6ea1a6a 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs
@@ -99,6 +99,7 @@ public async Task> GetListAsync(GrantApplica
appDto.ContactTitle = app.ApplicantAgent?.Title;
appDto.ContactBusinessPhone = app.ApplicantAgent?.Phone;
appDto.ContactCellPhone = app.ApplicantAgent?.Phone2;
+ appDto.ApplicationLinks = ObjectMapper.Map, List>(app.ApplicationLinks?.ToList() ?? []);
if (paymentsFeatureEnabled && paymentRequestsByApplication.Count > 0)
{
@@ -964,7 +965,6 @@ public async Task> GetActions(Guid applicati
List>(externalActionsList);
// NOTE: Authorization is applied on the AppService layer and is false by default
- // TODO: Replace placeholder loop with authorization handler mapped to permissions
// AUTHORIZATION HANDLING
actionDtos.ForEach(async item =>
{
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/UpdateApplicantProfileCacheHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/UpdateApplicantProfileCacheHandler.cs
new file mode 100644
index 000000000..04e684505
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/UpdateApplicantProfileCacheHandler.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using Unity.GrantManager.Applicants;
+using Unity.GrantManager.Intakes.Events;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.EventBus;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.TenantManagement;
+
+namespace Unity.GrantManager.Intakes.Handlers
+{
+ public class UpdateApplicantProfileCacheHandler(
+ IRepository applicantTenantMapRepository,
+ ICurrentTenant currentTenant,
+ ITenantRepository tenantRepository,
+ ILogger logger)
+ : ILocalEventHandler, ITransientDependency
+ {
+ ///
+ /// Handles an application process event by updating or creating an applicant-to-tenant mapping based on the
+ /// event data.
+ ///
+ /// If the mapping for the applicant and tenant already exists, the method updates its
+ /// last updated timestamp; otherwise, it creates a new mapping. If required data is missing from the event, the
+ /// method returns without performing any operation. Errors encountered during processing are logged but not
+ /// propagated.
+ /// The event data containing information about the application and form submission. Cannot be null and must
+ /// include valid Application and ApplicationFormSubmission objects.
+ /// A task that represents the asynchronous operation.
+ public async Task HandleEventAsync(ApplicationProcessEvent eventData)
+ {
+ if (eventData.ApplicationFormSubmission == null || eventData.Application == null)
+ {
+ return;
+ }
+
+ try
+ {
+ var submission = eventData.ApplicationFormSubmission;
+ var subUsername = submission.OidcSub.Contains('@')
+ ? submission.OidcSub[..submission.OidcSub.IndexOf('@')].ToUpperInvariant()
+ : submission.OidcSub.ToUpperInvariant();
+
+ if (string.IsNullOrWhiteSpace(subUsername))
+ {
+ logger.LogWarning("OidcSub is empty for submission {SubmissionId}", submission.Id);
+ return;
+ }
+
+ var tenantId = submission.TenantId ?? currentTenant.Id;
+ if (tenantId == null)
+ {
+ logger.LogWarning("Unable to determine tenant for submission {SubmissionId}", submission.Id);
+ return;
+ }
+
+ // Get tenant name from host context
+ string tenantName;
+ using (currentTenant.Change(null))
+ {
+ var tenant = await tenantRepository.GetAsync(tenantId.Value);
+ tenantName = tenant.Name;
+
+ // Check if mapping already exists
+ var queryable = await applicantTenantMapRepository.GetQueryableAsync();
+ var existingMapping = await queryable
+ .FirstOrDefaultAsync(m => m.OidcSubUsername == subUsername && m.TenantId == tenantId.Value);
+
+ if (existingMapping != null)
+ {
+ // Update LastUpdated timestamp
+ existingMapping.LastUpdated = DateTime.UtcNow;
+ await applicantTenantMapRepository.UpdateAsync(existingMapping);
+ logger.LogDebug("Updated ApplicantTenantMap for {SubUsername} in tenant {TenantName}", subUsername, tenantName);
+ }
+ else
+ {
+ // Create new mapping
+ var newMapping = new ApplicantTenantMap
+ {
+ OidcSubUsername = subUsername,
+ TenantId = tenantId.Value,
+ TenantName = tenantName,
+ LastUpdated = DateTime.UtcNow
+ };
+ await applicantTenantMapRepository.InsertAsync(newMapping);
+ logger.LogInformation("Created ApplicantTenantMap for {SubUsername} in tenant {TenantName}", subUsername, tenantName);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error updating ApplicantTenantMap for submission {SubmissionId}",
+ eventData.ApplicationFormSubmission.Id);
+ }
+ }
+ }
+}
+
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 2393f31c9..858bf9640 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeFormSubmissionManager.cs
@@ -63,7 +63,7 @@ public async Task ProcessFormSubmissionAsync(ApplicationForm applicationFo
var newSubmission = new ApplicationFormSubmission
{
- OidcSub = Guid.Empty.ToString(),
+ OidcSub = IntakeSubmissionHelper.ExtractOidcSub(formSubmission.submission),
ApplicantId = application.ApplicantId,
ApplicationFormId = applicationForm.Id,
ChefsSubmissionGuid = intakeMap.SubmissionId ?? $"{Guid.Empty}",
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeSubmissionHelper.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeSubmissionHelper.cs
new file mode 100644
index 000000000..8f5e249e1
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/IntakeSubmissionHelper.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Generic;
+
+namespace Unity.GrantManager.Intakes
+{
+ public static class IntakeSubmissionHelper
+ {
+ ///
+ /// Possible paths to search for the OIDC sub identifier in a submission object.
+ /// Paths are checked in order until a non-empty value is found.
+ /// Format: "property->nestedProperty->deeplyNestedProperty"
+ ///
+ private static readonly string[] SubSearchPaths =
+ [
+ "submission->data->applicantAgent->sub",
+ "submission->data->hiddenApplicantAgent->sub",
+ "createdBy"
+ ];
+
+ ///
+ /// Extracts the OIDC sub identifier from a submission, excluding the IDP suffix (after @)
+ /// Searches through configured paths until a value is found
+ ///
+ /// The dynamic submission object from CHEFS
+ /// The normalized (uppercase) sub identifier, or Guid.Empty string if not found
+ public static string ExtractOidcSub(dynamic submission)
+ {
+ try
+ {
+ string? sub = null;
+
+ // Try each search path until we find a value
+ foreach (var path in SubSearchPaths)
+ {
+ sub = GetValueFromPath(submission, path);
+ if (!string.IsNullOrWhiteSpace(sub))
+ {
+ break;
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(sub))
+ {
+ return Guid.Empty.ToString();
+ }
+
+ // Extract the identifier part before the @ symbol and convert to uppercase
+ var atIndex = sub.IndexOf('@');
+ if (atIndex == 0)
+ {
+ // @ at the beginning means no identifier
+ return Guid.Empty.ToString();
+ }
+
+ if (atIndex > 0)
+ {
+ return sub[..atIndex].ToUpperInvariant();
+ }
+
+ // No @ symbol found, return the whole sub uppercased
+ return sub.ToUpperInvariant();
+ }
+ catch
+ {
+ return Guid.Empty.ToString();
+ }
+ }
+
+ ///
+ /// Traverses a dynamic object using a path string
+ ///
+ /// The dynamic object to traverse
+ /// Path string with properties separated by "->"
+ /// The value as a string, or null if not found
+ private static string? GetValueFromPath(dynamic obj, string path)
+ {
+ try
+ {
+ var properties = path.Split("->");
+ dynamic? current = obj;
+
+ foreach (var property in properties)
+ {
+ if (current == null)
+ {
+ return null;
+ }
+
+ // Access the property dynamically
+ current = GetProperty(current, property);
+ }
+
+ return current?.ToString();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Gets a property from a dynamic object safely
+ ///
+ private static dynamic? GetProperty(dynamic obj, string propertyName)
+ {
+ try
+ {
+ // Try as dictionary first (works for ExpandoObject and similar types)
+ if (obj is IDictionary dictionary)
+ {
+ return dictionary.TryGetValue(propertyName, out var value) ? value : null;
+ }
+
+ // Try reflection for regular objects
+ var type = obj.GetType();
+ var property = type.GetProperty(propertyName);
+
+ if (property != null)
+ {
+ return property.GetValue(obj);
+ }
+
+ // Try as indexer access
+ return obj[propertyName];
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Settings/SettingsConstants.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Settings/SettingsConstants.cs
index 12b374c80..7104a6f7e 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Settings/SettingsConstants.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Settings/SettingsConstants.cs
@@ -30,6 +30,7 @@ public static class BackgroundJobs
public const string IntakeResync_Expression = "GrantManager.BackgroundJobs.IntakeResync_Expression";
public const string IntakeResync_NumDaysToCheck = "GrantManager.BackgroundJobs.IntakeResync_NumDaysToCheck";
public const string DataHealthCheckMonitor_Expression = "GrantManager.BackgroundJobs.DataHealthCheckMonitor_Expression";
+ public const string ApplicantTenantMapReconciliation_Expression = "GrantManager.BackgroundJobs.ApplicantTenantMapReconciliation_Expression";
}
}
}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applicants/ApplicantTenantMap.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applicants/ApplicantTenantMap.cs
new file mode 100644
index 000000000..311bbb678
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applicants/ApplicantTenantMap.cs
@@ -0,0 +1,12 @@
+using System;
+using Volo.Abp.Domain.Entities.Auditing;
+
+namespace Unity.GrantManager.Applicants;
+
+public class ApplicantTenantMap : CreationAuditedAggregateRoot
+{
+ public string OidcSubUsername { get; set; } = string.Empty;
+ public Guid TenantId { get; set; }
+ public string TenantName { get; set; } = string.Empty;
+ public DateTime LastUpdated { get; set; }
+}
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs
index 969505975..41ed1793d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs
@@ -52,6 +52,7 @@ public virtual ApplicationStatus ApplicationStatus
public virtual Collection? Assessments { get; set; }
public virtual Collection? ApplicationTags { get; set; }
public virtual Collection? ApplicationAssignments { get; set; }
+ public virtual Collection? ApplicationLinks { get; set; }
public string ProjectName { get; set; } = string.Empty;
public string ReferenceNo { get; set; } = string.Empty;
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs
index 24c1e25d3..b810fed02 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationManager.cs
@@ -40,7 +40,7 @@ public ApplicationManager(
public void ConfigureWorkflow(StateMachine stateMachine, bool isDirectApproval = false)
{
- // TODO: ENSURE APPLICATION STATE MACHINE MATCHES WORKFLOW IN AB#8375
+ // NOTE: ENSURE APPLICATION STATE MACHINE MATCHES WORKFLOW IN AB#8375
stateMachine.Configure(GrantApplicationState.OPEN)
.InitialTransition(GrantApplicationState.SUBMITTED)
.Permit(GrantApplicationAction.Withdraw, GrantApplicationState.WITHDRAWN) // 2.2 - Withdraw; Role: Reviewer
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs
index faf66a702..9b8581039 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Settings/GrantManagerSettingDefinitionProvider.cs
@@ -65,7 +65,9 @@ private static void AddBackgroundJobSettingDefinition(ISettingDefinitionContext
// 24 = 1 am So 24 + 8 UTC = 9
{ PaymentSettingsConstants.BackgroundJobs.CasFinancialNotificationSummary_ProducerExpression, "0 0 9 1/1 * ? *" },
// Run hourly
- { SettingsConstants.BackgroundJobs.DataHealthCheckMonitor_Expression, "0 0 * 1/1 * ? *" }
+ { SettingsConstants.BackgroundJobs.DataHealthCheckMonitor_Expression, "0 0 * 1/1 * ? *" },
+ // 2 AM PST = 10 AM UTC
+ { SettingsConstants.BackgroundJobs.ApplicantTenantMapReconciliation_Expression, "0 0 10 1/1 * ? *" }
};
foreach (var setting in backGroundSchedules)
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs
index ed8b28f03..e0f7fcfa9 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs
@@ -1,7 +1,8 @@
-using Microsoft.EntityFrameworkCore;
-using System.Linq;
-using Unity.GrantManager.Locality;
-using Unity.GrantManager.Tokens;
+using Microsoft.EntityFrameworkCore;
+using System.Linq;
+using Unity.GrantManager.Applicants;
+using Unity.GrantManager.Locality;
+using Unity.GrantManager.Tokens;
using Volo.Abp.AuditLogging.EntityFrameworkCore;
using Volo.Abp.BackgroundJobs.EntityFrameworkCore;
using Volo.Abp.Data;
@@ -29,8 +30,9 @@ public class GrantManagerDbContext :
IIdentityDbContext,
ITenantManagementDbContext
{
- /* Add DbSet properties for your Aggregate Roots / Entities here. */
-
+ /* Add DbSet properties for your Aggregate Roots / Entities here. */
+
+ public DbSet ApplicantTenantMaps { get; set; }
public DbSet DynamicUrls { get; set; }
public DbSet Sectors { get; set; }
public DbSet SubSectors { get; set; }
@@ -150,14 +152,27 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
b.ConfigureByConvention();
});
- modelBuilder.Entity(b =>
- {
- b.ToTable(GrantManagerConsts.DbTablePrefix + "TenantTokens",
- GrantManagerConsts.DbSchema);
-
- b.ConfigureByConvention();
- });
-
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable(GrantManagerConsts.DbTablePrefix + "TenantTokens",
+ GrantManagerConsts.DbSchema);
+
+ b.ConfigureByConvention();
+ });
+
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable(GrantManagerConsts.DbTablePrefix + "ApplicantTenantMaps",
+ GrantManagerConsts.DbSchema);
+
+ b.ConfigureByConvention();
+
+ b.HasIndex(x => x.OidcSubUsername);
+ b.HasIndex(x => new { x.OidcSubUsername, x.TenantId }).IsUnique();
+ });
+
+
var allEntityTypes = modelBuilder.Model.GetEntityTypes();
foreach (var type in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary)).Select(t => t.ClrType))
{
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs
index 4d697f4f9..c463a6a4d 100644
--- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs
@@ -297,7 +297,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
GrantManagerConsts.DbSchema);
b.ConfigureByConvention();
- b.HasOne().WithMany().HasForeignKey(x => x.ApplicationId).IsRequired();
+ b.HasOne()
+ .WithMany(a => a.ApplicationLinks)
+ .HasForeignKey(x => x.ApplicationId)
+ .IsRequired();
b.Property(x => x.LinkType)
.IsRequired()
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260205165444_FixSnapshotForDynamicUrls.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260205165444_FixSnapshotForDynamicUrls.Designer.cs
new file mode 100644
index 000000000..9883913f6
--- /dev/null
+++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260205165444_FixSnapshotForDynamicUrls.Designer.cs
@@ -0,0 +1,2567 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Unity.GrantManager.EntityFrameworkCore;
+using Volo.Abp.EntityFrameworkCore;
+
+#nullable disable
+
+namespace Unity.GrantManager.Migrations.HostMigrations
+{
+ [DbContext(typeof(GrantManagerDbContext))]
+ [Migration("20260205165444_FixSnapshotForDynamicUrls")]
+ partial class FixSnapshotForDynamicUrls
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql)
+ .HasAnnotation("ProductVersion", "9.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("BlobData")
+ .HasColumnType("bytea")
+ .HasColumnName("blob_data");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_blob_triggers", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("CalendarName")
+ .HasColumnType("text")
+ .HasColumnName("calendar_name");
+
+ b.Property("Calendar")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("calendar");
+
+ b.HasKey("SchedulerName", "CalendarName");
+
+ b.ToTable("qrtz_calendars", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("CronExpression")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("cron_expression");
+
+ b.Property("TimeZoneId")
+ .HasColumnType("text")
+ .HasColumnName("time_zone_id");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_cron_triggers", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("EntryId")
+ .HasColumnType("text")
+ .HasColumnName("entry_id");
+
+ b.Property("FiredTime")
+ .HasColumnType("bigint")
+ .HasColumnName("fired_time");
+
+ b.Property("InstanceName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("instance_name");
+
+ b.Property("IsNonConcurrent")
+ .HasColumnType("bool")
+ .HasColumnName("is_nonconcurrent");
+
+ b.Property("JobGroup")
+ .HasColumnType("text")
+ .HasColumnName("job_group");
+
+ b.Property("JobName")
+ .HasColumnType("text")
+ .HasColumnName("job_name");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("priority");
+
+ b.Property("RequestsRecovery")
+ .HasColumnType("bool")
+ .HasColumnName("requests_recovery");
+
+ b.Property("ScheduledTime")
+ .HasColumnType("bigint")
+ .HasColumnName("sched_time");
+
+ b.Property("State")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("state");
+
+ b.Property("TriggerGroup")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("TriggerName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.HasKey("SchedulerName", "EntryId");
+
+ b.HasIndex("InstanceName")
+ .HasDatabaseName("idx_qrtz_ft_trig_inst_name");
+
+ b.HasIndex("JobGroup")
+ .HasDatabaseName("idx_qrtz_ft_job_group");
+
+ b.HasIndex("JobName")
+ .HasDatabaseName("idx_qrtz_ft_job_name");
+
+ b.HasIndex("RequestsRecovery")
+ .HasDatabaseName("idx_qrtz_ft_job_req_recovery");
+
+ b.HasIndex("TriggerGroup")
+ .HasDatabaseName("idx_qrtz_ft_trig_group");
+
+ b.HasIndex("TriggerName")
+ .HasDatabaseName("idx_qrtz_ft_trig_name");
+
+ b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup")
+ .HasDatabaseName("idx_qrtz_ft_trig_nm_gp");
+
+ b.ToTable("qrtz_fired_triggers", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("JobName")
+ .HasColumnType("text")
+ .HasColumnName("job_name");
+
+ b.Property("JobGroup")
+ .HasColumnType("text")
+ .HasColumnName("job_group");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("IsDurable")
+ .HasColumnType("bool")
+ .HasColumnName("is_durable");
+
+ b.Property("IsNonConcurrent")
+ .HasColumnType("bool")
+ .HasColumnName("is_nonconcurrent");
+
+ b.Property("IsUpdateData")
+ .HasColumnType("bool")
+ .HasColumnName("is_update_data");
+
+ b.Property("JobClassName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("job_class_name");
+
+ b.Property("JobData")
+ .HasColumnType("bytea")
+ .HasColumnName("job_data");
+
+ b.Property("RequestsRecovery")
+ .HasColumnType("bool")
+ .HasColumnName("requests_recovery");
+
+ b.HasKey("SchedulerName", "JobName", "JobGroup");
+
+ b.HasIndex("RequestsRecovery")
+ .HasDatabaseName("idx_qrtz_j_req_recovery");
+
+ b.ToTable("qrtz_job_details", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("LockName")
+ .HasColumnType("text")
+ .HasColumnName("lock_name");
+
+ b.HasKey("SchedulerName", "LockName");
+
+ b.ToTable("qrtz_locks", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.HasKey("SchedulerName", "TriggerGroup");
+
+ b.ToTable("qrtz_paused_trigger_grps", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("InstanceName")
+ .HasColumnType("text")
+ .HasColumnName("instance_name");
+
+ b.Property("CheckInInterval")
+ .HasColumnType("bigint")
+ .HasColumnName("checkin_interval");
+
+ b.Property("LastCheckInTime")
+ .HasColumnType("bigint")
+ .HasColumnName("last_checkin_time");
+
+ b.HasKey("SchedulerName", "InstanceName");
+
+ b.ToTable("qrtz_scheduler_state", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("BooleanProperty1")
+ .HasColumnType("bool")
+ .HasColumnName("bool_prop_1");
+
+ b.Property("BooleanProperty2")
+ .HasColumnType("bool")
+ .HasColumnName("bool_prop_2");
+
+ b.Property("DecimalProperty1")
+ .HasColumnType("numeric")
+ .HasColumnName("dec_prop_1");
+
+ b.Property("DecimalProperty2")
+ .HasColumnType("numeric")
+ .HasColumnName("dec_prop_2");
+
+ b.Property("IntegerProperty1")
+ .HasColumnType("integer")
+ .HasColumnName("int_prop_1");
+
+ b.Property("IntegerProperty2")
+ .HasColumnType("integer")
+ .HasColumnName("int_prop_2");
+
+ b.Property("LongProperty1")
+ .HasColumnType("bigint")
+ .HasColumnName("long_prop_1");
+
+ b.Property("LongProperty2")
+ .HasColumnType("bigint")
+ .HasColumnName("long_prop_2");
+
+ b.Property("StringProperty1")
+ .HasColumnType("text")
+ .HasColumnName("str_prop_1");
+
+ b.Property("StringProperty2")
+ .HasColumnType("text")
+ .HasColumnName("str_prop_2");
+
+ b.Property("StringProperty3")
+ .HasColumnType("text")
+ .HasColumnName("str_prop_3");
+
+ b.Property("TimeZoneId")
+ .HasColumnType("text")
+ .HasColumnName("time_zone_id");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_simprop_triggers", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("RepeatCount")
+ .HasColumnType("bigint")
+ .HasColumnName("repeat_count");
+
+ b.Property("RepeatInterval")
+ .HasColumnType("bigint")
+ .HasColumnName("repeat_interval");
+
+ b.Property("TimesTriggered")
+ .HasColumnType("bigint")
+ .HasColumnName("times_triggered");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.ToTable("qrtz_simple_triggers", (string)null);
+ });
+
+ modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b =>
+ {
+ b.Property("SchedulerName")
+ .HasColumnType("text")
+ .HasColumnName("sched_name");
+
+ b.Property("TriggerName")
+ .HasColumnType("text")
+ .HasColumnName("trigger_name");
+
+ b.Property("TriggerGroup")
+ .HasColumnType("text")
+ .HasColumnName("trigger_group");
+
+ b.Property("CalendarName")
+ .HasColumnType("text")
+ .HasColumnName("calendar_name");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("EndTime")
+ .HasColumnType("bigint")
+ .HasColumnName("end_time");
+
+ b.Property("JobData")
+ .HasColumnType("bytea")
+ .HasColumnName("job_data");
+
+ b.Property("JobGroup")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("job_group");
+
+ b.Property("JobName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("job_name");
+
+ b.Property("MisfireInstruction")
+ .HasColumnType("smallint")
+ .HasColumnName("misfire_instr");
+
+ b.Property("NextFireTime")
+ .HasColumnType("bigint")
+ .HasColumnName("next_fire_time");
+
+ b.Property("PreviousFireTime")
+ .HasColumnType("bigint")
+ .HasColumnName("prev_fire_time");
+
+ b.Property("Priority")
+ .HasColumnType("integer")
+ .HasColumnName("priority");
+
+ b.Property("StartTime")
+ .HasColumnType("bigint")
+ .HasColumnName("start_time");
+
+ b.Property("TriggerState")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_state");
+
+ b.Property("TriggerType")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("trigger_type");
+
+ b.HasKey("SchedulerName", "TriggerName", "TriggerGroup");
+
+ b.HasIndex("NextFireTime")
+ .HasDatabaseName("idx_qrtz_t_next_fire_time");
+
+ b.HasIndex("TriggerState")
+ .HasDatabaseName("idx_qrtz_t_state");
+
+ b.HasIndex("NextFireTime", "TriggerState")
+ .HasDatabaseName("idx_qrtz_t_nft_st");
+
+ b.HasIndex("SchedulerName", "JobName", "JobGroup");
+
+ b.ToTable("qrtz_triggers", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Integrations.DynamicUrl", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("DeleterId")
+ .HasColumnType("uuid")
+ .HasColumnName("DeleterId");
+
+ b.Property("DeletionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("DeletionTime");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("IsDeleted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("IsDeleted");
+
+ b.Property("KeyName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("Url")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("DynamicUrls", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Locality.Community", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RegionalDistrictCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Communities", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Locality.EconomicRegion", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("EconomicRegionCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("EconomicRegionName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.HasKey("Id");
+
+ b.ToTable("EconomicRegions", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Locality.ElectoralDistrict", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("ElectoralDistrictCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ElectoralDistrictName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.HasKey("Id");
+
+ b.ToTable("ElectoralDistricts", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Locality.RegionalDistrict", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("EconomicRegionCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("RegionalDistrictCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RegionalDistrictName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("RegionalDistricts", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("SectorCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SectorName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Sectors", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("SectorId")
+ .HasColumnType("uuid");
+
+ b.Property("SubSectorCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SubSectorName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SectorId");
+
+ b.ToTable("SubSectors", (string)null);
+ });
+
+ modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("CreationTime");
+
+ b.Property("CreatorId")
+ .HasColumnType("uuid")
+ .HasColumnName("CreatorId");
+
+ b.Property("LastModificationTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("LastModificationTime");
+
+ b.Property("LastModifierId")
+ .HasColumnType("uuid")
+ .HasColumnName("LastModifierId");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("TenantTokens", (string)null);
+ });
+
+ modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ApplicationName")
+ .HasMaxLength(96)
+ .HasColumnType("character varying(96)")
+ .HasColumnName("ApplicationName");
+
+ b.Property("BrowserInfo")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("BrowserInfo");
+
+ b.Property("ClientId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("ClientId");
+
+ b.Property("ClientIpAddress")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("ClientIpAddress");
+
+ b.Property("ClientName")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("ClientName");
+
+ b.Property("Comments")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("Comments");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("ConcurrencyStamp");
+
+ b.Property("CorrelationId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("CorrelationId");
+
+ b.Property("Exceptions")
+ .HasColumnType("text");
+
+ b.Property("ExecutionDuration")
+ .HasColumnType("integer")
+ .HasColumnName("ExecutionDuration");
+
+ b.Property("ExecutionTime")
+ .HasColumnType("timestamp without time zone");
+
+ b.Property("ExtraProperties")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("HttpMethod")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)")
+ .HasColumnName("HttpMethod");
+
+ b.Property("HttpStatusCode")
+ .HasColumnType("integer")
+ .HasColumnName("HttpStatusCode");
+
+ b.Property("ImpersonatorTenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("ImpersonatorTenantId");
+
+ b.Property("ImpersonatorTenantName")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("ImpersonatorTenantName");
+
+ b.Property("ImpersonatorUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("ImpersonatorUserId");
+
+ b.Property("ImpersonatorUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("ImpersonatorUserName");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.Property("TenantName")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("TenantName");
+
+ b.Property("Url")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("Url");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("UserId");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("UserName");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TenantId", "ExecutionTime");
+
+ b.HasIndex("TenantId", "UserId", "ExecutionTime");
+
+ b.ToTable("AuditLogs", (string)null);
+ });
+
+ modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AuditLogId")
+ .HasColumnType("uuid")
+ .HasColumnName("AuditLogId");
+
+ b.Property("ExecutionDuration")
+ .HasColumnType("integer")
+ .HasColumnName("ExecutionDuration");
+
+ b.Property("ExecutionTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("ExecutionTime");
+
+ b.Property("ExtraProperties")
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("MethodName")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("MethodName");
+
+ b.Property("Parameters")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)")
+ .HasColumnName("Parameters");
+
+ b.Property("ServiceName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("ServiceName");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuditLogId");
+
+ b.HasIndex("TenantId", "ServiceName", "MethodName", "ExecutionTime");
+
+ b.ToTable("AuditLogActions", (string)null);
+ });
+
+ modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AuditLogId")
+ .HasColumnType("uuid")
+ .HasColumnName("AuditLogId");
+
+ b.Property("ChangeTime")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("ChangeTime");
+
+ b.Property("ChangeType")
+ .HasColumnType("smallint")
+ .HasColumnName("ChangeType");
+
+ b.Property("EntityId")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("EntityId");
+
+ b.Property("EntityTenantId")
+ .HasColumnType("uuid");
+
+ b.Property("EntityTypeFullName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("EntityTypeFullName");
+
+ b.Property("ExtraProperties")
+ .HasColumnType("text")
+ .HasColumnName("ExtraProperties");
+
+ b.Property("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("TenantId");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AuditLogId");
+
+ b.HasIndex("TenantId", "EntityTypeFullName", "EntityId");
+
+ b.ToTable("EntityChanges", (string)null);
+ });
+
+ modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("EntityChangeId")
+ .HasColumnType("uuid");
+
+ b.Property