diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..da9598417e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:abp.io)", + "Bash(abp help *)", + "Bash(abp add-package *)", + "Bash(abp install-libs *)", + "Bash(grep -o \"Bundling\\\\\\\\\\\\\\\\[A-Za-z]*\\\\.cs\" \"C:\\\\Users\\\\JPASTA\\\\.claude\\\\projects\\\\c--Work-Economy-UnityOnGit-Unity\\\\99eba763-b7c1-4034-93ab-933cde9f37db\\\\tool-results\\\\by1m66sl2.txt\")", + "Bash(find 'C:\\\\Users\\\\JPASTA/.nuget/packages/volo.abp.aspnetcore.mvc.ui' -iname *.dll)", + "Bash(find 'C:\\\\Users\\\\JPASTA/.nuget/packages' -maxdepth 1 -iname *bundl*)", + "Bash(find 'C:\\\\Users\\\\JPASTA/.nuget/packages/volo.abp.aspnetcore.mvc.ui.theme.shared' -iname *.dll)", + "Bash(tasklist //FI \"PID eq 19060\")" + ] + } +} diff --git a/.gitignore b/.gitignore index 8cae5022ff..fcc01ade32 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,10 @@ bld/ [Oo]bj/ [Ll]og/ [Ll]ogs/ +!applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Logs/ +!applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/ +!applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Logs/ +!applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/ # Visual Studio cache/options directory .vs/ @@ -118,4 +122,9 @@ appsettings.json /applications/Orchestrator *.env -/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package-lock.json \ No newline at end of file +/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package-lock.json +/applications/Unity.AutoUI/cypress/config/dev.json +/applications/Unity.AutoUI/cypress/config/dev2.json +/applications/Unity.AutoUI/cypress/config/test.json +/applications/Unity.AutoUI/cypress/config/uat.json +/applications/Unity.AutoUI/cypress/config/prod.json diff --git a/applications/Unity.AutoUI/CypressTestLauncher.bat b/applications/Unity.AutoUI/CypressTestLauncher.bat index a88219b77b..035acfc7ef 100644 --- a/applications/Unity.AutoUI/CypressTestLauncher.bat +++ b/applications/Unity.AutoUI/CypressTestLauncher.bat @@ -1,5 +1,6 @@ @echo off +@echo off setlocal cd /d "%~dp0" -powershell -NoProfile -ExecutionPolicy Bypass -NoExit -Command "$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;$projectPath=(Get-Location).Path;$form=New-Object System.Windows.Forms.Form;$form.Text='Cypress Test Launcher';$form.Size=New-Object System.Drawing.Size(420,240);$form.StartPosition='CenterScreen';$form.AutoScaleMode=[System.Windows.Forms.AutoScaleMode]::None;$envLabel=New-Object System.Windows.Forms.Label;$envLabel.Text='Select Environment:';$envLabel.AutoSize=$true;$envLabel.Location=New-Object System.Drawing.Point(15,15);$envBox=New-Object System.Windows.Forms.ComboBox;$envBox.Location=New-Object System.Drawing.Point(15,35);$envBox.Size=New-Object System.Drawing.Size(380,25);$envBox.DropDownStyle='DropDownList';$envBox.Items.Add('Please select an environment');$envBox.Items.Add('DEV');$envBox.Items.Add('DEV2');$envBox.Items.Add('TEST');$envBox.Items.Add('UAT');$envBox.Items.Add('PROD');$envBox.SelectedIndex=0;$modeLabel=New-Object System.Windows.Forms.Label;$modeLabel.Text='Select Mode:';$modeLabel.AutoSize=$true;$modeLabel.Location=New-Object System.Drawing.Point(15,70);$modeBox=New-Object System.Windows.Forms.ComboBox;$modeBox.Location=New-Object System.Drawing.Point(15,90);$modeBox.Size=New-Object System.Drawing.Size(380,25);$modeBox.DropDownStyle='DropDownList';$modeBox.Items.Add('Please select a mode');$modeBox.Items.Add('GUI');$modeBox.Items.Add('Headless');$modeBox.SelectedIndex=0;$run=New-Object System.Windows.Forms.Button;$run.Text='Launch Cypress';$run.Location=New-Object System.Drawing.Point(15,135);$run.Size=New-Object System.Drawing.Size(380,32);$run.Add_Click({try{if($envBox.SelectedIndex -eq 0 -or $modeBox.SelectedIndex -eq 0){[System.Windows.Forms.MessageBox]::Show('Please select both an environment and a mode.','Missing Selection');return};Set-Location $projectPath;$envName=$envBox.SelectedItem;$envFile='.\\cypress.'+$envName+'.env.json';if(Test-Path $envFile){Copy-Item $envFile '.\\cypress.env.json' -Force}else{[System.Windows.Forms.MessageBox]::Show('Environment file not found: '+$envFile,'Missing Env File');return};if($modeBox.SelectedItem -eq 'Headless'){Start-Process powershell -ArgumentList '-NoExit','-Command',\"cd '$projectPath'; npx cypress run\"}else{Start-Process powershell -ArgumentList '-NoExit','-Command',\"cd '$projectPath'; npx cypress open\"}}catch{[System.Windows.Forms.MessageBox]::Show($_.Exception.Message,'Cypress Launcher Error')}});$form.Controls.Add($envLabel);$form.Controls.Add($envBox);$form.Controls.Add($modeLabel);$form.Controls.Add($modeBox);$form.Controls.Add($run);$form.ShowDialog()" +powershell -NoProfile -ExecutionPolicy Bypass -NoExit -Command "$ErrorActionPreference='Stop';$ProgressPreference='SilentlyContinue';Add-Type -AssemblyName System.Windows.Forms;Add-Type -AssemblyName System.Drawing;$projectPath=(Get-Location).Path;$form=New-Object System.Windows.Forms.Form;$form.Text='Cypress Test Launcher';$form.Size=New-Object System.Drawing.Size(420,240);$form.StartPosition='CenterScreen';$form.AutoScaleMode=[System.Windows.Forms.AutoScaleMode]::None;$envLabel=New-Object System.Windows.Forms.Label;$envLabel.Text='Select Environment:';$envLabel.AutoSize=$true;$envLabel.Location=New-Object System.Drawing.Point(15,15);$envBox=New-Object System.Windows.Forms.ComboBox;$envBox.Location=New-Object System.Drawing.Point(15,35);$envBox.Size=New-Object System.Drawing.Size(380,25);$envBox.DropDownStyle='DropDownList';$envBox.Items.Add('Please select an environment');$envBox.Items.Add('DEV');$envBox.Items.Add('DEV2');$envBox.Items.Add('TEST');$envBox.Items.Add('UAT');$envBox.Items.Add('PROD');$envBox.SelectedIndex=0;$modeLabel=New-Object System.Windows.Forms.Label;$modeLabel.Text='Select Mode:';$modeLabel.AutoSize=$true;$modeLabel.Location=New-Object System.Drawing.Point(15,70);$modeBox=New-Object System.Windows.Forms.ComboBox;$modeBox.Location=New-Object System.Drawing.Point(15,90);$modeBox.Size=New-Object System.Drawing.Size(380,25);$modeBox.DropDownStyle='DropDownList';$modeBox.Items.Add('Please select a mode');$modeBox.Items.Add('GUI');$modeBox.Items.Add('Headless');$modeBox.SelectedIndex=0;$run=New-Object System.Windows.Forms.Button;$run.Text='Launch Cypress';$run.Location=New-Object System.Drawing.Point(15,135);$run.Size=New-Object System.Drawing.Size(380,32);$run.Add_Click({try{if($envBox.SelectedIndex -eq 0 -or $modeBox.SelectedIndex -eq 0){[System.Windows.Forms.MessageBox]::Show('Please select both an environment and a mode.','Missing Selection');return};Set-Location $projectPath;$envName=$envBox.SelectedItem.ToString().ToLowerInvariant();$envFile=Join-Path $projectPath ('cypress\config\'+$envName+'.json');if(-not (Test-Path $envFile)){[System.Windows.Forms.MessageBox]::Show('Environment file not found: '+$envFile,'Missing Env File');return};if($modeBox.SelectedItem -eq 'Headless'){Start-Process powershell -ArgumentList '-NoExit','-ExecutionPolicy','Bypass','-Command',\"`$env:UNITY_CYPRESS_ENV='$envName'; cd '$projectPath'; node .\\scripts\\run-cypress.js run --browser chrome\"}else{Start-Process powershell -ArgumentList '-NoExit','-ExecutionPolicy','Bypass','-Command',\"`$env:UNITY_CYPRESS_ENV='$envName'; cd '$projectPath'; node .\\scripts\\run-cypress.js open --browser chrome\"}}catch{[System.Windows.Forms.MessageBox]::Show($_.Exception.Message,'Cypress Launcher Error')}});$form.Controls.Add($envLabel);$form.Controls.Add($envBox);$form.Controls.Add($modeLabel);$form.Controls.Add($modeBox);$form.Controls.Add($run);$form.ShowDialog()" diff --git a/applications/Unity.AutoUI/cypress.config.ts b/applications/Unity.AutoUI/cypress.config.ts index 53936349c8..83619ae6ca 100644 --- a/applications/Unity.AutoUI/cypress.config.ts +++ b/applications/Unity.AutoUI/cypress.config.ts @@ -3,10 +3,67 @@ import FormData from "form-data"; import fs from "fs"; import path from "path"; +function loadLocalEnvironmentConfig(requestedEnvironment?: string): { + environmentName: string; + environmentSource: string; + environmentConfig: Record; +} { + let environmentName: string; + let environmentSource: string; + + if (requestedEnvironment) { + environmentName = requestedEnvironment.toLowerCase(); + environmentSource = "--env environment"; + } else if (process.env.UNITY_CYPRESS_ENV) { + environmentName = process.env.UNITY_CYPRESS_ENV.toLowerCase(); + environmentSource = "UNITY_CYPRESS_ENV"; + } else { + environmentName = "dev"; + environmentSource = "default"; + } + + const environmentFilePath = path.resolve( + "cypress", + "config", + `${environmentName}.json`, + ); + + try { + const content = fs.readFileSync(environmentFilePath, "utf-8"); + return { + environmentName, + environmentSource, + environmentConfig: JSON.parse(content) as Record, + }; + } catch { + return { environmentName, environmentSource, environmentConfig: {} }; + } +} + // https://docs.cypress.io/guides/references/configuration export default defineConfig({ e2e: { - setupNodeEvents(on) { + setupNodeEvents(on, config) { + // Supports selecting the environment either via Cypress's native + // `--env environment=` CLI flag or the UNITY_CYPRESS_ENV OS + // environment variable (the flag takes precedence). + const { environmentName, environmentSource, environmentConfig } = + loadLocalEnvironmentConfig( + config.env?.environment as string | undefined, + ); + + // eslint-disable-next-line no-console + console.log( + `\n[Cypress] Running against environment: ${environmentName} (source: ${environmentSource})\n`, + ); + + on("after:run", () => { + // eslint-disable-next-line no-console + console.log( + `\n[Cypress] Finished run against environment: ${environmentName} (source: ${environmentSource})\n`, + ); + }); + on("task", { readJsonIfExists(filePath: string): Record | null { try { @@ -59,6 +116,17 @@ export default defineConfig({ return response.json(); }, }); + + return { + ...config, + baseUrl: + (environmentConfig["webapp.url"] as string | undefined) || + config.baseUrl, + env: { + ...config.env, + ...environmentConfig, + }, + }; }, specPattern: [ "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", diff --git a/applications/Unity.AutoUI/cypress/config/dev.json.example b/applications/Unity.AutoUI/cypress/config/dev.json.example index b34148f6e7..48035f0b27 100644 --- a/applications/Unity.AutoUI/cypress/config/dev.json.example +++ b/applications/Unity.AutoUI/cypress/config/dev.json.example @@ -7,5 +7,6 @@ "test2password": "", "TEST_EMAIL_TO": "", "TEST_EMAIL_CC": "", - "TEST_EMAIL_BCC": "" + "TEST_EMAIL_BCC": "", + "chefsApiKey": "" } diff --git a/applications/Unity.AutoUI/cypress/config/test.json.example b/applications/Unity.AutoUI/cypress/config/test.json.example index 0b012b37d0..5d9b2c47a1 100644 --- a/applications/Unity.AutoUI/cypress/config/test.json.example +++ b/applications/Unity.AutoUI/cypress/config/test.json.example @@ -7,5 +7,6 @@ "test2password": "", "TEST_EMAIL_TO": "", "TEST_EMAIL_CC": "", - "TEST_EMAIL_BCC": "" + "TEST_EMAIL_BCC": "", + "chefsApiKey": "" } diff --git a/applications/Unity.AutoUI/cypress/config/uat.json.example b/applications/Unity.AutoUI/cypress/config/uat.json.example index 2ab1fe261f..2510724582 100644 --- a/applications/Unity.AutoUI/cypress/config/uat.json.example +++ b/applications/Unity.AutoUI/cypress/config/uat.json.example @@ -7,5 +7,6 @@ "test2password": "", "TEST_EMAIL_TO": "", "TEST_EMAIL_CC": "", - "TEST_EMAIL_BCC": "" + "TEST_EMAIL_BCC": "", + "chefsApiKey": "" } diff --git a/applications/Unity.AutoUI/cypress/e2e/login.cy.ts b/applications/Unity.AutoUI/cypress/e2e/login.cy.ts index 3735041179..b88cb0bbf3 100644 --- a/applications/Unity.AutoUI/cypress/e2e/login.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/login.cy.ts @@ -1,3 +1,4 @@ +/// import { LoginPageInstance, NavigationPageInstance } from "../utilities"; describe('Grant Manager Login and Logout', () => { @@ -8,10 +9,9 @@ describe('Grant Manager Login and Logout', () => { loginPage.login() loginPage.verifyOnGrantApplications() - // Verify Default Grant Program tenant is selected + navPage.switchToDefaultGrantsProgramIfAvailable() navPage.verifyCurrentTenant('Default Grants Program') - // Logout (terminal action) loginPage.quickLogout() }) }) diff --git a/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts b/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts index 6bcf9a65df..5549ae3f16 100644 --- a/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/navigation.cy.ts @@ -11,10 +11,9 @@ describe('Grant Manager Login and Top Navigation', () => { it('Verify navigation options in the top banner', () => { - // 3.) Verify Default Grant Program tenant is selected. + navPage.switchToDefaultGrantsProgramIfAvailable() navPage.verifyCurrentTenant('Default Grants Program') - // 4.) Ensure all expected headings are present. navPage.verifyAllNavItemsExist() // 5.) Applications diff --git a/applications/Unity.AutoUI/cypress/fixtures/metabase.json b/applications/Unity.AutoUI/cypress/fixtures/metabase.json index 051308782f..1ba2314590 100644 --- a/applications/Unity.AutoUI/cypress/fixtures/metabase.json +++ b/applications/Unity.AutoUI/cypress/fixtures/metabase.json @@ -18,7 +18,7 @@ }, { "unityEnv": "PROD", - "baseURL": "https://unity-reporting.apps.gold.devops.gov.bc.ca/" + "baseURL": "https://unity-reporting.apps.silver.devops.gov.bc.ca/" } ] } \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 9e58c6ddeb..0efc3c2bd7 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -506,10 +506,25 @@ const APPLICATIONS_PATH = "GrantApplications"; return; } - // Priority 3: fetch the latest matching submission from the Unity API + // Priority 3: fetch the latest matching submission from the Unity API, + // falling back to seeding a fresh one via CHEFS if none currently + // match (e.g. every existing seeded submission has already been + // approved by a prior run) — so this spec is self-sufficient and + // doesn't depend on a separate seed step running first. cy.fetchDynamicSubmission(TEST_CONFIG.fetchOptions).then((id) => { - submissionId = id; - cy.log(`✅ Fetched dynamic submission ID: ${submissionId}`); + if (id) { + submissionId = id; + cy.log(`✅ Fetched dynamic submission ID: ${submissionId}`); + return; + } + + cy.log( + "⚠️ No matching submission found — seeding a fresh one via CHEFS", + ); + cy.seedApprovalFlowSubmission().then((seededId) => { + submissionId = seededId; + cy.log(`✅ Seeded and using submission ID: ${submissionId}`); + }); }); }, ); @@ -619,6 +634,25 @@ const APPLICATIONS_PATH = "GrantApplications"; .enterSupplierNumber(TEST_CONFIG.supplierNumber) .clickElsewhere() .clickPaymentInfoSave(); + + // Saving the supplier number calls out to CAS to resolve it, which can + // transiently fail with "GetAuthTokenAsync: Error retrieving Token". + // When that happens the save silently doesn't attach a supplier, leaving + // SupplierId empty for the rest of the flow — dismiss and retry once. + cy.get("body").then(($body) => { + const hasTokenError = + $body.text().includes("GetAuthTokenAsync") || + $body.text().includes("Error retrieving Token"); + + if (hasTokenError) { + cy.log("⚠️ Transient CAS token error on payment save — retrying once"); + detailsPage.dismissErrorModalIfPresent(); + detailsPage + .enterSupplierNumber(TEST_CONFIG.supplierNumber) + .clickElsewhere() + .clickPaymentInfoSave(); + } + }); }); // Must use function() (not arrow) so this.skip() is accessible diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json index 0bf9000101..50d724e8d8 100644 --- a/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json @@ -1,21 +1,31 @@ { - "environments": { - "test": { - "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", - "formId": "46e25863-0ead-4aa8-897f-51e45f79e137", - "versionId": "4ef52ead-2cc3-4bdb-a7b7-73be983a7838" - }, - "dev": { - "baseURL": "https://chefs-dev.apps.silver.devops.gov.bc.ca", - "formId": "233f47f9-b566-46c3-926a-73d565bf710f", - "versionId": "1e209d6b-46f5-4ddb-bc79-6e04033231cb" - }, - "uat": { - "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", - "formId": "f2f45aa7-62c5-49ca-8846-b214e02adb46", - "versionId": "1d4d73ec-00e7-4b57-98c9-49d1e0c7d15b" - } + "environments": { + "dev": { + "baseURL": "https://chefs-dev.apps.silver.devops.gov.bc.ca", + "formId": "233f47f9-b566-46c3-926a-73d565bf710f", + "versionId": "1e209d6b-46f5-4ddb-bc79-6e04033231cb" }, + "dev2": { + "baseURL": "https://chefs-dev.apps.silver.devops.gov.bc.ca", + "formId": "92c0df4c-34f9-4a9a-a1b4-61c12495749c", + "versionId": "8218c285-7fa3-42df-b445-7e8ae835fad0" + }, + "test": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "46e25863-0ead-4aa8-897f-51e45f79e137", + "versionId": "4ef52ead-2cc3-4bdb-a7b7-73be983a7838" + }, + "uat": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "f2f45aa7-62c5-49ca-8846-b214e02adb46", + "versionId": "1d4d73ec-00e7-4b57-98c9-49d1e0c7d15b" + }, + "prod": { + "baseURL": "https://submit.digital.gov.bc.ca", + "formId": "4defd3bf-f57c-4969-94a1-ced745a99ccf", + "versionId": "d8736cde-a11a-41be-8800-084f2dfcb825" + } + }, "headers": { "Accept": "application/json", "Content-Type": "application/json" diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts index 0b7124aaf0..a309bdd85e 100644 --- a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -3,337 +3,26 @@ export {}; /** - * CHEFS Form Submission Seeder + * CHEFS Form Submission Seeder (standalone entry point) * - * Creates exactly one submitted form entry in CHEFS via API and writes its - * confirmation ID to cypress/scripts/last-submission-id.json so that - * ApprovalFlow.cy.ts can pick it up without a dynamic API lookup. + * Thin wrapper around cy.seedApprovalFlowSubmission() so `npm run test:seed` + * / `npm run test:approval-flow` can still seed a submission as a separate + * step before ApprovalFlow.cy.ts runs. The actual seeding logic lives in + * cypress/support/commands.ts so ApprovalFlow.cy.ts can also call it + * directly as a fallback when no existing submission matches its search + * criteria, without requiring this spec to run first. * * Configuration files: * - cypress/scripts/chefs-submission-payload.json — form submission data * - cypress/scripts/chefs-api-config.json — API config and headers */ -interface ChefsEnvironment { - baseURL: string; - formId: string; - versionId: string; -} - -interface ChefsApiConfig { - environments: Record; - headers: Record; -} - -interface ChefsSubmissionPayload { - draft?: boolean; - submission: { - state: string; - metadata: { - origin: string; - referrer: string; - }; - data: Record; - }; -} - -const TOKEN_PROPERTY_KEYS = [ - "access_token", - "accessToken", - "token", - "id_token", - "idToken", -]; - -function isJwtLike(value: string): boolean { - return /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(value); -} - -function extractTokenFromString(value: string): string { - const trimmed = value.trim(); - - if (trimmed.toLowerCase().startsWith("bearer ")) { - const bearerToken = trimmed.replace(/^Bearer\s+/i, "").trim(); - if (isJwtLike(bearerToken)) { - return bearerToken; - } - } - - if (isJwtLike(trimmed)) { - return trimmed; - } - - try { - return extractTokenFromValue(JSON.parse(trimmed)); - } catch { - return ""; - } -} - -function extractTokenFromArray(values: unknown[]): string { - for (const value of values) { - const token = extractTokenFromValue(value); - if (token) { - return token; - } - } - - return ""; -} - -function extractTokenFromObject(value: Record): string { - for (const key of TOKEN_PROPERTY_KEYS) { - const token = extractTokenFromValue(value[key]); - if (token) { - return token; - } - } - - return extractTokenFromArray(Object.values(value)); -} - -function extractTokenFromValue(value: unknown): string { - if (typeof value === "string") { - return extractTokenFromString(value); - } - - if (Array.isArray(value)) { - return extractTokenFromArray(value); - } - - if (value && typeof value === "object") { - return extractTokenFromObject(value as Record); - } - - return ""; -} - -function extractTokenFromStorage(win: Window): string { - const storages = [win.localStorage, win.sessionStorage]; - - for (const storage of storages) { - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index); - if (!key) { - continue; - } - - const value = storage.getItem(key); - if (!value) { - continue; - } - - const token = extractTokenFromValue(value); - if (token) { - return token; - } - } - } - - return ""; -} - -function getChefsHostname(baseURL: string): string { - return new URL(baseURL).hostname; -} - -function waitForIdentityRedirectOrAuthenticatedChefsPage( - baseURL: string, - timeout: number, -): void { - const chefsHostname = getChefsHostname(baseURL); - - cy.location("hostname", { timeout }).should((hostname) => { - const onChefs = hostname === chefsHostname; - const onBcGovIdentity = hostname.endsWith("gov.bc.ca"); - - expect( - onChefs || onBcGovIdentity, - `Expected CHEFS or BC Gov identity host, got '${hostname}'`, - ).to.eq(true); - }); -} - -function completeChefsLogin(environment: ChefsEnvironment, timeout: number): void { - const chefsHostname = getChefsHostname(environment.baseURL); - - cy.visit(`${environment.baseURL}/app`); - - // The header auth button hydrates asynchronously after an auth-state check; - // wait for its label rather than just its (empty) wrapper to exist. - cy.get("#loginButton", { timeout }).should("be.visible").click(); - - // CHEFS shows an identity-provider picker (IDIR / IDIR MFA / BC Services - // Card / BCeID); each button carries a stable data-test attribute. - cy.get('[data-test="idir"]', { timeout }).should("be.visible").click(); - - waitForIdentityRedirectOrAuthenticatedChefsPage(environment.baseURL, timeout); - - cy.location("hostname", { timeout }).then((hostname) => { - if (hostname === chefsHostname) { - cy.log("Already logged in to CHEFS"); - return; - } - - cy.get("#user", { timeout }) - .should("be.visible") - .clear() - .type(Cypress.env("test1username"), { log: false }); - - cy.get("#password", { timeout }) - .should("be.visible") - .clear() - .type(Cypress.env("test1password"), { log: false }); - - cy.contains("Continue", { timeout }).should("be.visible").click(); - - cy.location("hostname", { timeout }).should("eq", chefsHostname); - }); -} - -function visitChefsForm(environment: ChefsEnvironment, timeout: number): void { - cy.visit(`${environment.baseURL}/app/form/submit?f=${environment.formId}`); - cy.location("hostname", { timeout }).should( - "eq", - getChefsHostname(environment.baseURL), - ); - cy.location("pathname", { timeout }).should("include", "/app"); -} - const isProd = (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === "prod"; (isProd ? describe.skip : describe)("CHEFS Approval Flow Seeder", () => { - let apiConfig: ChefsApiConfig; - let submissionPayload: ChefsSubmissionPayload; - let environment: ChefsEnvironment; - let authToken: string; - - before(() => { - const authTimeout = 60000; - - cy.readFile("cypress/scripts/chefs-api-config.json").then((config) => { - apiConfig = config; - - const envKey = ( - Cypress.env("CHEFS_ENV") || - Cypress.env("environment") || - "test" - ).toLowerCase(); - - environment = config.environments[envKey]; - - expect( - environment, - `Missing CHEFS environment configuration for '${envKey}'`, - ).to.exist; - - cy.log(`Using environment: ${envKey}`); - cy.log(`Base URL: ${environment.baseURL}`); - cy.log(`Form ID: ${environment.formId}`); - cy.log(`Version ID: ${environment.versionId}`); - - cy.readFile("cypress/scripts/chefs-submission-payload.json").then( - (payload) => { - submissionPayload = payload; - submissionPayload.submission.metadata.origin = environment.baseURL; - submissionPayload.submission.metadata.referrer = `${environment.baseURL}/app/form/submit?f=${environment.formId}`; - - cy.log( - `Payload loaded with ${ - Object.keys(payload.submission.data).length - } data fields`, - ); - cy.log(`Metadata origin set to: ${environment.baseURL}`); - }, - ); - - let capturedToken = ""; - - cy.intercept("**/app/api/v1/**", (req) => { - const authHeader = req.headers["authorization"] as string; - if (authHeader && !capturedToken) { - capturedToken = authHeader.replace(/^Bearer\s+/i, ""); - } - }).as("chefsApiCalls"); - - completeChefsLogin(environment, authTimeout); - visitChefsForm(environment, authTimeout); - - cy.window({ timeout: authTimeout }) - .should((win) => { - const tokenFromStorage = extractTokenFromStorage(win); - const resolvedToken = capturedToken || tokenFromStorage; - - expect( - resolvedToken, - "Waiting for authenticated CHEFS API token from request or browser storage", - ).to.not.equal(""); - - if (!capturedToken && tokenFromStorage) { - capturedToken = tokenFromStorage; - } - }) - .then(() => { - authToken = capturedToken; - cy.log("✅ Auth token captured from CHEFS login"); - }); - }); - }); - - // Creates the single submission that ApprovalFlow.cy.ts will process. - // The confirmation ID is written to last-submission-id.json and consumed - // by the "Fetch submission ID from API" step in ApprovalFlow.cy.ts. it("Create approval flow submission", () => { - const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; - - cy.log(`Submitting to: ${submissionUrl}`); - - cy.request({ - method: "POST", - url: submissionUrl, - headers: { - ...apiConfig.headers, - Authorization: `Bearer ${authToken}`, - Origin: environment.baseURL, - Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, - }, - body: submissionPayload, - failOnStatusCode: false, - }).then((response) => { - cy.log(`Response Status: ${response.status}`); - cy.log( - `Response Body: ${JSON.stringify(response.body).substring(0, 200)}...`, - ); - - if (response.status === 401) { - cy.log("❌ 401 Unauthorized - Token is expired or invalid"); - cy.log("📖 See cypress/scripts/README.md for token refresh instructions"); - throw new Error( - "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", - ); - } - - expect(response.status).to.be.oneOf([200, 201]); - expect(response.body).to.have.property("id"); - - const confirmationId = response.body.confirmationId || response.body.id; - cy.log(`✅ Submission created with ID: ${response.body.id}`); - cy.log(`✅ Confirmation ID: ${confirmationId}`); - - cy.writeFile("cypress/scripts/last-submission-id.json", { - submissionId: confirmationId, - createdAt: new Date().toISOString(), - }); - - expect(response.body).to.have.property("formVersionId", environment.versionId); - - if (response.body.formId) { - expect(response.body.formId).to.eq(environment.formId); - } else { - cy.log("⚠️ Response doesn't include formId (CHEFS version-dependent)"); - } - }); + cy.seedApprovalFlowSubmission(); }); }); diff --git a/applications/Unity.AutoUI/cypress/support/auth.ts b/applications/Unity.AutoUI/cypress/support/auth.ts index aa47482b8a..8e5be6aeea 100644 --- a/applications/Unity.AutoUI/cypress/support/auth.ts +++ b/applications/Unity.AutoUI/cypress/support/auth.ts @@ -191,7 +191,10 @@ function ensureGrantApplicationsPage(timeout: number): void { * Performs the actual login flow */ function performLogin(options: LoginOptions = {}): void { - const baseUrl = options.baseUrl || (Cypress.env("webapp.url") as string); + const baseUrl = + options.baseUrl || + (Cypress.env("webapp.url") as string | undefined) || + Cypress.config("baseUrl"); const useMfa = options.useMfa || false; const timeout = options.timeout || 20000; @@ -237,7 +240,10 @@ export function loginIfNeeded( options: LoginOptions = {}, ): void { const username = options.username || (Cypress.env("test1username") as string); - const baseUrl = options.baseUrl || (Cypress.env("webapp.url") as string); + const baseUrl = + options.baseUrl || + (Cypress.env("webapp.url") as string | undefined) || + Cypress.config("baseUrl"); const sessionId = `unity-${baseUrl}-${username}`; cy.session( diff --git a/applications/Unity.AutoUI/cypress/support/commands.ts b/applications/Unity.AutoUI/cypress/support/commands.ts index 07edc350ba..9d17908f68 100644 --- a/applications/Unity.AutoUI/cypress/support/commands.ts +++ b/applications/Unity.AutoUI/cypress/support/commands.ts @@ -331,9 +331,11 @@ Cypress.Commands.add( } if (applications.length === 0) { - throw new Error( - "No applications found matching the specified criteria", - ); + Cypress.log({ + name: "fetch", + message: "⚠️ No applications found matching the specified criteria", + }); + return ""; } // Sort applications (default: by submissionDate descending for latest first) @@ -386,3 +388,132 @@ Cypress.Commands.add( Cypress.Commands.add("fetchAllSubmissions", () => { return fetchGrantApplications(); }); + +// ============ CHEFS Submission Seeding ============ + +interface ChefsSeedEnvironment { + baseURL: string; + formId: string; + versionId: string; +} + +interface ChefsSeedApiConfig { + environments: Record; + headers: Record; +} + +interface ChefsSeedSubmissionPayload { + draft?: boolean; + submission: { + state: string; + metadata: { + origin: string; + referrer: string; + }; + data: Record; + }; +} + +/** + * Seeds exactly one submission in CHEFS via its form-level Basic Auth API + * key (formId:apiKey) — a single direct POST, no browser/IDIR login + * required — and writes the confirmation ID to + * cypress/scripts/last-submission-id.json. + * + * This is the same auth approach used by cypress- seeder/support/apiCalls.ts, + * applied here so ApprovalFlow.cy.ts (or any other spec) can call it + * directly as a fallback when no existing submission matches its search + * criteria, without needing a separate seed step — and without the + * IDIR/MFA-picker flakiness a UI-driven login carries. + * + * Requires a `chefsApiKey` value for the current environment, set in + * cypress/config/{env}.json (gitignored) — the form's API key from CHEFS + * form management settings. + * + * @returns Chainable containing the confirmation ID of the created submission + */ +Cypress.Commands.add("seedApprovalFlowSubmission", () => { + const envKey = ( + Cypress.env("CHEFS_ENV") || + Cypress.env("environment") || + "test" + ).toLowerCase(); + + if (envKey === "prod") { + throw new Error( + "seedApprovalFlowSubmission() is disabled for PROD — seeding real submissions in production is not supported.", + ); + } + + const apiKey = Cypress.env("chefsApiKey") as string | undefined; + + expect( + apiKey, + `Missing chefsApiKey for '${envKey}' — set it in cypress/config/${envKey}.json`, + ).to.exist; + + return cy + .readFile("cypress/scripts/chefs-api-config.json") + .then((apiConfig) => { + const environment = apiConfig.environments[envKey]; + + expect( + environment, + `Missing CHEFS environment configuration for '${envKey}'`, + ).to.exist; + + cy.log(`🌱 Seeding submission via CHEFS API key — environment: ${envKey}`); + + return cy + .readFile( + "cypress/scripts/chefs-submission-payload.json", + ) + .then((submissionPayload) => { + submissionPayload.submission.metadata.origin = environment.baseURL; + submissionPayload.submission.metadata.referrer = `${environment.baseURL}/app/form/submit?f=${environment.formId}`; + + const basicCredentials = btoa(`${environment.formId}:${apiKey}`); + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + + return cy + .request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Basic ${basicCredentials}`, + }, + body: { + ...submissionPayload, + createdBy: `${Cypress.env("test1username")}@idir`, + updatedBy: `${Cypress.env("test1username")}@idir`, + }, + failOnStatusCode: false, + }) + .then((response) => { + if (response.status === 401) { + throw new Error( + "Authentication failed (401) while seeding a submission via cy.seedApprovalFlowSubmission(). Check that chefsApiKey is valid for this environment's form.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + + const confirmationId = + response.body.confirmationId || response.body.id; + Cypress.log({ + name: "seed", + message: `✅ Seeded submission with confirmation ID: ${confirmationId}`, + }); + + return cy + .writeFile("cypress/scripts/last-submission-id.json", { + submissionId: confirmationId, + createdAt: new Date().toISOString(), + }) + .then(() => confirmationId as string); + }); + }); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/support/index.d.ts b/applications/Unity.AutoUI/cypress/support/index.d.ts index 979f84c4a5..194e6a9216 100644 --- a/applications/Unity.AutoUI/cypress/support/index.d.ts +++ b/applications/Unity.AutoUI/cypress/support/index.d.ts @@ -89,7 +89,8 @@ declare namespace Cypress { * Uses session cookies automatically from Cypress. * * @param options - Optional filters for selecting submissions - * @returns Chainable containing the confirmation ID + * @returns Chainable containing the confirmation ID, or an empty string + * if no application matches the given filters (does not throw) * * @example * // Get first available submission @@ -108,5 +109,17 @@ declare namespace Cypress { * @returns Chainable containing array of grant applications */ fetchAllSubmissions(): Chainable; + + /** + * Seeds exactly one submission in CHEFS via its form-level Basic Auth + * API key (formId:apiKey) — a single direct POST, no browser/IDIR login + * required — and writes the confirmation ID to + * cypress/scripts/last-submission-id.json. Disabled for PROD. Requires + * a `chefsApiKey` value for the current environment in + * cypress/config/{env}.json. + * + * @returns Chainable containing the confirmation ID of the created submission + */ + seedApprovalFlowSubmission(): Chainable; } } diff --git a/applications/Unity.AutoUI/package.json b/applications/Unity.AutoUI/package.json index cbdade5aeb..9ec62d2b54 100644 --- a/applications/Unity.AutoUI/package.json +++ b/applications/Unity.AutoUI/package.json @@ -1,13 +1,13 @@ { "scripts": { - "test": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/e2e/**/*.cy.ts' --browser chrome", - "test:e2e": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/e2e/**/*.cy.ts' --browser chrome", - "test:regression-headed": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/**/*.cy.ts' --headed --browser chrome", - "test:regression-headless": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/**/*.cy.ts' --headless --browser chrome", - "test:open": "env -u ELECTRON_RUN_AS_NODE cypress open --browser chrome", - "test:seed": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/scripts/chefs-api-submission.cy.ts' --browser chrome", - "test:approval-flow": "npm run test:seed && env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/ApprovalFlow.cy.ts' --headed --browser chrome", - "test:approval-flow-headless": "npm run test:seed && env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/ApprovalFlow.cy.ts' --browser chrome" + "test": "node ./scripts/run-cypress.js run --spec \"cypress/e2e/**/*.cy.ts\" --browser chrome", + "test:e2e": "node ./scripts/run-cypress.js run --spec \"cypress/e2e/**/*.cy.ts\" --browser chrome", + "test:regression-headed": "node ./scripts/run-cypress.js run --spec \"cypress/regression/**/*.cy.ts\" --headed --browser chrome", + "test:regression-headless": "node ./scripts/run-cypress.js run --spec \"cypress/regression/**/*.cy.ts\" --headless --browser chrome", + "test:open": "node ./scripts/run-cypress.js open --browser chrome", + "test:seed": "node ./scripts/run-cypress.js run --spec \"cypress/scripts/chefs-api-submission.cy.ts\" --browser chrome", + "test:approval-flow": "npm run test:seed && node ./scripts/run-cypress.js run --spec \"cypress/regression/ApprovalFlow.cy.ts\" --headed --browser chrome", + "test:approval-flow-headless": "npm run test:seed && node ./scripts/run-cypress.js run --spec \"cypress/regression/ApprovalFlow.cy.ts\" --browser chrome" }, "dependencies": { "form-data": "^4.0.5", diff --git a/applications/Unity.AutoUI/scripts/run-cypress.js b/applications/Unity.AutoUI/scripts/run-cypress.js new file mode 100644 index 0000000000..cbe1f4801e --- /dev/null +++ b/applications/Unity.AutoUI/scripts/run-cypress.js @@ -0,0 +1,25 @@ +const path = require("path"); +const { spawnSync } = require("child_process"); + +delete process.env.ELECTRON_RUN_AS_NODE; + +const cypressCli = path.resolve( + __dirname, + "..", + "node_modules", + "cypress", + "bin", + "cypress", +); + +const result = spawnSync(process.execPath, [cypressCli, ...process.argv.slice(2)], { + stdio: "inherit", + env: process.env, + shell: false, +}); + +if (result.error) { + throw result.error; +} + +process.exit(result.status ?? 1); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Responses/FormWorksheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Responses/FormWorksheetResponse.cs index 1ee1d837bc..6490ce3660 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Responses/FormWorksheetResponse.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Responses/FormWorksheetResponse.cs @@ -3,4 +3,6 @@ namespace Unity.AI.Responses; public class FormWorksheetResponse { public string Worksheet { get; set; } = string.Empty; + + public string? FailureReason { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Runtime/Execution/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Runtime/Execution/OpenAIRuntimeService.cs index 5da7549cc1..fabef95333 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Runtime/Execution/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Runtime/Execution/OpenAIRuntimeService.cs @@ -310,7 +310,10 @@ public async Task GenerateFormWorksheetAsync(FormWorkshee { Worksheet = result.Outcome == AIOperationOutcome.Success ? AIResponseJson.CleanJsonResponse(result.Content) - : "{}" + : "{}", + FailureReason = result.Outcome == AIOperationOutcome.Success + ? null + : result.FailureReason ?? $"Worksheet generation failed with outcome {result.Outcome}." }; } catch (OperationCanceledException) @@ -320,7 +323,7 @@ public async Task GenerateFormWorksheetAsync(FormWorkshee catch (Exception ex) { _logger.LogError(ex, "Form worksheet generation failed."); - return new FormWorksheetResponse(); + return new FormWorksheetResponse { FailureReason = ex.Message }; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/CreateNotificationLogDto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/CreateNotificationLogDto.cs new file mode 100644 index 0000000000..f31f411687 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/CreateNotificationLogDto.cs @@ -0,0 +1,27 @@ +using System; + +namespace Unity.Notifications.Logs; + +public class CreateNotificationLogDto +{ + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public Guid? SenderUserId { get; set; } + public string? SenderDisplayName { get; set; } + public NotificationLogType NotificationType { get; set; } + public NotificationLogChannel Channel { get; set; } + public NotificationLogSeverity Severity { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? SourceReference { get; set; } + public string? PayloadJson { get; set; } + public string? CorrelationId { get; set; } + public bool IsDeliveredRealtime { get; set; } + public string? DeliveryTarget { get; set; } + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public string? StackExcerpt { get; set; } + public string? CommitSha { get; set; } + public string? Environment { get; set; } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/GetNotificationLogsInput.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/GetNotificationLogsInput.cs new file mode 100644 index 0000000000..f0aeddcb9c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/GetNotificationLogsInput.cs @@ -0,0 +1,16 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Unity.Notifications.Logs; + +public class GetNotificationLogsInput : PagedAndSortedResultRequestDto +{ + public DateTime? DateFrom { get; set; } + public DateTime? DateTo { get; set; } + public NotificationLogType? NotificationType { get; set; } + public NotificationLogSeverity? Severity { get; set; } + public NotificationLogChannel? Channel { get; set; } + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public string? SearchText { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/INotificationLogsAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/INotificationLogsAppService.cs new file mode 100644 index 0000000000..43912723c5 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/INotificationLogsAppService.cs @@ -0,0 +1,9 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.Notifications.Logs; + +public interface INotificationLogsAppService +{ + Task CreateAsync(CreateNotificationLogDto input); +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/INotificationLogsReadAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/INotificationLogsReadAppService.cs new file mode 100644 index 0000000000..19ea9b2760 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/INotificationLogsReadAppService.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; + +namespace Unity.Notifications.Logs; + +public interface INotificationLogsReadAppService +{ + Task> GetListAsync(GetNotificationLogsInput input); + + Task GetAsync(Guid id); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogCreatedEto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogCreatedEto.cs new file mode 100644 index 0000000000..8eaee3abf2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogCreatedEto.cs @@ -0,0 +1,18 @@ +using System; + +namespace Unity.Notifications.Logs; + +public class NotificationLogCreatedEto +{ + public Guid Id { get; set; } + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public NotificationLogType NotificationType { get; set; } + public NotificationLogChannel Channel { get; set; } + public NotificationLogSeverity Severity { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? CorrelationId { get; set; } + public DateTime CreationTime { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogDetailDto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogDetailDto.cs new file mode 100644 index 0000000000..38e812a397 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogDetailDto.cs @@ -0,0 +1,27 @@ +using System; + +namespace Unity.Notifications.Logs; + +public class NotificationLogDetailDto +{ + public Guid Id { get; set; } + public DateTime CreationTime { get; set; } + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public NotificationLogType NotificationType { get; set; } + public NotificationLogSeverity Severity { get; set; } + public NotificationLogChannel Channel { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? SourceReference { get; set; } + public string? CorrelationId { get; set; } + public bool IsDeliveredRealtime { get; set; } + public string? DeliveryTarget { get; set; } + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public string? StackExcerpt { get; set; } + public string? CommitSha { get; set; } + public string? Environment { get; set; } + public string? PayloadJson { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogListDto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogListDto.cs new file mode 100644 index 0000000000..dd2c4bde10 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Logs/NotificationLogListDto.cs @@ -0,0 +1,20 @@ +using System; + +namespace Unity.Notifications.Logs; + +public class NotificationLogListDto +{ + public Guid Id { get; set; } + public DateTime CreationTime { get; set; } + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public string UserDisplayName { get; set; } = string.Empty; + public NotificationLogType NotificationType { get; set; } + public NotificationLogSeverity Severity { get; set; } + public NotificationLogChannel Channel { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? CorrelationId { get; set; } + public bool IsDeliveredRealtime { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs index ce368255c9..b5a9b3fbe2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissionDefinitionProvider.cs @@ -20,6 +20,10 @@ public override void Define(IPermissionDefinitionContext context) NotificationsPermissions.Email.Send, L($"Permission:{NotificationsPermissions.Email.Send}")); + notificationsPermissions.AddChild( + NotificationsPermissions.Email.SendBulk, + L($"Permission:{NotificationsPermissions.Email.SendBulk}")); + notificationsPermissions.AddChild( NotificationsPermissions.Email.DeleteDraft, L($"Permission:{NotificationsPermissions.Email.DeleteDraft}")); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs index 20bd2b4ec1..b3ef9af03b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Permissions/NotificationsPermissions.cs @@ -11,6 +11,7 @@ public static class Email { public const string Default = "Notifications.Email"; public const string Send = "Notifications.Email.Send"; + public const string SendBulk = "Notifications.Email.SendBulk"; public const string DeleteDraft = "Notifications.Email.DeleteDraft"; public const string CancelScheduled = "Notifications.Email.CancelScheduled"; public const string Schedule = "Notifications.Email.Schedule"; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index f5f7b3224d..f5e46f0976 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -33,7 +32,6 @@ public class EmailNotificationService( ISettingManager settingManager, IFeatureChecker featureChecker, IConfiguration configuration, - IWebHostEnvironment webHostEnvironment, IMarkdownRenderer markdownRenderer) : ApplicationService, IEmailNotificationService { @@ -284,7 +282,8 @@ private async Task RenderCommentNotificationTemplateAsync(string current } /// - /// Loads an email template from the Views/EmailTemplates directory. + /// Loads an email template from the Application assembly's embedded resources. + /// The resource name follows the format Unity.Notifications.EmailTemplates.{templateName}.cshtml. /// /// Template name without extension (e.g., "CommentNotification") /// Template content as a string @@ -292,32 +291,17 @@ private async Task LoadEmailTemplateAsync(string templateName) { try { - // Content root is at: .../Unity.GrantManager/src/Unity.GrantManager.Web - // We need to go up 2 levels to reach Unity.GrantManager, then into modules - var contentRoot = webHostEnvironment.ContentRootPath; - - var templatePath = Path.Combine( - contentRoot, - "..", - "..", - "modules", - "Unity.Notifications", - "src", - "Unity.Notifications.Web", - "Views", - "EmailTemplates", - $"{templateName}.cshtml"); - - // Normalize the path to remove .. references - templatePath = Path.GetFullPath(templatePath); - - if (!File.Exists(templatePath)) + var assembly = typeof(EmailNotificationService).Assembly; + var resourceName = $"Unity.Notifications.EmailTemplates.{templateName}.cshtml"; + await using var templateStream = assembly.GetManifestResourceStream(resourceName); + + if (templateStream == null) { - throw new FileNotFoundException($"Email template not found at: {templatePath}"); + throw new FileNotFoundException($"Embedded email template not found: {resourceName}"); } - var content = await File.ReadAllTextAsync(templatePath); - return content; + using var reader = new StreamReader(templateStream); + return await reader.ReadToEndAsync(); } catch (Exception ex) { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Logs/NotificationLogsAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Logs/NotificationLogsAppService.cs new file mode 100644 index 0000000000..eee197f95f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Logs/NotificationLogsAppService.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Uow; + +namespace Unity.Notifications.Logs; + +[RemoteService(false)] +public class NotificationLogsAppService( + INotificationLogsRepository notificationLogsRepository, + ILocalEventBus localEventBus, + IUnitOfWorkManager unitOfWorkManager) + : NotificationsAppService, INotificationLogsAppService +{ + public async Task CreateAsync(CreateNotificationLogDto input) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + + var entity = new NotificationLog + { + TenantId = input.TenantId, + UserId = input.UserId, + SenderUserId = input.SenderUserId, + SenderDisplayName = input.SenderDisplayName, + NotificationType = input.NotificationType, + Channel = input.Channel, + Severity = input.Severity, + Title = input.Title, + Message = input.Message, + Source = input.Source, + SourceReference = input.SourceReference, + PayloadJson = input.PayloadJson, + CorrelationId = input.CorrelationId, + IsDeliveredRealtime = input.IsDeliveredRealtime, + DeliveryTarget = input.DeliveryTarget, + ExceptionType = input.ExceptionType, + ExceptionMessage = input.ExceptionMessage, + StackExcerpt = input.StackExcerpt, + CommitSha = input.CommitSha, + Environment = input.Environment + }; + + entity = await notificationLogsRepository.InsertAsync(entity, autoSave: true); + + await localEventBus.PublishAsync(new NotificationLogCreatedEto + { + Id = entity.Id, + TenantId = entity.TenantId, + UserId = entity.UserId, + NotificationType = entity.NotificationType, + Channel = entity.Channel, + Severity = entity.Severity, + Title = entity.Title, + Message = entity.Message, + Source = entity.Source, + CorrelationId = entity.CorrelationId, + CreationTime = entity.CreationTime + }); + + await uow.CompleteAsync(); + + return entity.Id; + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Logs/NotificationLogsReadAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Logs/NotificationLogsReadAppService.cs new file mode 100644 index 0000000000..6e5192e43c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Logs/NotificationLogsReadAppService.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; + +namespace Unity.Notifications.Logs; + +[Authorize(IdentityConsts.ITOperationsPermissionName)] +public class NotificationLogsReadAppService( + INotificationLogsRepository notificationLogsRepository, + IIdentityUserRepository identityUserRepository, + ICurrentTenant currentTenant) + : NotificationsAppService, INotificationLogsReadAppService +{ + public async Task> GetListAsync(GetNotificationLogsInput input) + { + var query = await notificationLogsRepository.GetQueryableAsync(); + + query = query.WhereIf(currentTenant.Id.HasValue, x => x.TenantId == currentTenant.Id); + + if (!currentTenant.Id.HasValue && input.TenantId.HasValue) + { + query = query.Where(x => x.TenantId == input.TenantId); + } + + if (input.UserId.HasValue) + { + query = query.Where(x => x.UserId == input.UserId); + } + + if (input.NotificationType.HasValue) + { + query = query.Where(x => x.NotificationType == input.NotificationType); + } + + if (input.Severity.HasValue) + { + query = query.Where(x => x.Severity == input.Severity); + } + + if (input.Channel.HasValue) + { + query = query.Where(x => x.Channel == input.Channel); + } + + if (input.DateFrom.HasValue) + { + query = query.Where(x => x.CreationTime >= input.DateFrom.Value); + } + + if (input.DateTo.HasValue) + { + var toDateExclusive = input.DateTo.Value.Date.AddDays(1); + query = query.Where(x => x.CreationTime < toDateExclusive); + } + + if (!string.IsNullOrWhiteSpace(input.SearchText)) + { + var term = input.SearchText.Trim(); + query = query.Where(x => + x.Title.Contains(term) || + x.Message.Contains(term) || + x.Source.Contains(term) || + (x.CorrelationId != null && x.CorrelationId.Contains(term))); + } + + var totalCount = await AsyncExecuter.CountAsync(query); + + var pageQuery = query + .OrderByDescending(x => x.CreationTime) + .Skip(input.SkipCount) + .Take(input.MaxResultCount) + .Select(x => new NotificationLogListDto + { + Id = x.Id, + CreationTime = x.CreationTime, + TenantId = x.TenantId, + UserId = x.UserId, + NotificationType = x.NotificationType, + Severity = x.Severity, + Channel = x.Channel, + Title = x.Title, + Message = x.Message, + Source = x.Source, + CorrelationId = x.CorrelationId, + IsDeliveredRealtime = x.IsDeliveredRealtime + }); + + var items = await AsyncExecuter.ToListAsync(pageQuery); + + var userDisplayNames = await GetUserDisplayNamesAsync( + items.Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct()); + + foreach (var item in items) + { + if (item.UserId.HasValue && userDisplayNames.TryGetValue(item.UserId.Value, out var displayName)) + { + item.UserDisplayName = displayName; + } + } + + return new PagedResultDto(totalCount, items); + } + + private async Task> GetUserDisplayNamesAsync(IEnumerable userIds) + { + var displayNames = new Dictionary(); + + foreach (var userId in userIds) + { + var user = await identityUserRepository.FindAsync(userId); + + if (user != null) + { + displayNames[userId] = $"{user.Name} {user.Surname}".Trim(); + } + } + + return displayNames; + } + + public async Task GetAsync(Guid id) + { + var query = await notificationLogsRepository.GetQueryableAsync(); + + query = query.WhereIf(currentTenant.Id.HasValue, x => x.TenantId == currentTenant.Id); + + var entity = await AsyncExecuter.FirstOrDefaultAsync(query.Where(x => x.Id == id)); + + if (entity == null) + { + throw new EntityNotFoundException(typeof(NotificationLog), id); + } + + return new NotificationLogDetailDto + { + Id = entity.Id, + CreationTime = entity.CreationTime, + TenantId = entity.TenantId, + UserId = entity.UserId, + NotificationType = entity.NotificationType, + Severity = entity.Severity, + Channel = entity.Channel, + Title = entity.Title, + Message = entity.Message, + Source = entity.Source, + SourceReference = entity.SourceReference, + CorrelationId = entity.CorrelationId, + IsDeliveredRealtime = entity.IsDeliveredRealtime, + DeliveryTarget = entity.DeliveryTarget, + ExceptionType = entity.ExceptionType, + ExceptionMessage = entity.ExceptionMessage, + StackExcerpt = entity.StackExcerpt, + CommitSha = entity.CommitSha, + Environment = entity.Environment, + PayloadJson = entity.PayloadJson + }; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsApplicationModule.cs index 828e253693..96079de1aa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/NotificationsApplicationModule.cs @@ -13,6 +13,7 @@ using Volo.Abp.Http.Client; using Unity.Modules.Shared.Http; using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Identity; namespace Unity.Notifications; @@ -23,7 +24,8 @@ namespace Unity.Notifications; typeof(AbpMapperlyModule), typeof(AbpBackgroundJobsModule), typeof(AbpBackgroundWorkersQuartzModule), - typeof(AbpHttpClientModule) + typeof(AbpHttpClientModule), + typeof(AbpIdentityDomainModule) )] public class NotificationsApplicationModule : AbpModule { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj index 5dd709e5a3..5fea35f0e5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj @@ -20,6 +20,7 @@ + @@ -30,6 +31,8 @@ + all diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Features/NotificationsFeatureConsts.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Features/NotificationsFeatureConsts.cs new file mode 100644 index 0000000000..0c49052197 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Features/NotificationsFeatureConsts.cs @@ -0,0 +1,6 @@ +namespace Unity.Notifications.Features; + +public static class NotificationsFeatureConsts +{ + public const string DirectMessaging = "Unity.Notifications.DirectMessaging"; +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json index 2d5375f419..c6bd3f3e5f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Localization/Notifications/en.json @@ -10,6 +10,7 @@ "Permission:Notifications": "Notifications", "Permission:Notifications.Email": "Email", "Permission:Notifications.Email.Send": "Send Email for Individual Application", + "Permission:Notifications.Email.SendBulk": "Send Bulk Email Notification", "Permission:Notifications.Email.DeleteDraft": "Delete Draft Email for Individual Application", "Permission:Notifications.Email.CancelScheduled": "Cancel Scheduled Email for Individual Application", "Permission:Notifications.Email.Schedule": "Schedule Email for Individual Application", @@ -20,6 +21,8 @@ "Permission:Notifications.NotificationList": "Notification List", "Permission:Notifications.NotificationList.View": "View Notification List", "Menu:Notifications": "Notifications", + "Menu:NotificationLogs": "Notification Logs", + "Menu:RealtimeOps": "Unity Messaging", "Search": "Search", "Filter": "Filter", "Export": "Export", @@ -47,6 +50,63 @@ "NotificationList:DateRange:Custom": "Custom Ranges", "NotificationList:DateFrom": "Date From", "NotificationList:DateTo": "Date To", - "NotificationList:DateCannotBeFuture": "The date cannot be in the future" + "NotificationList:DateCannotBeFuture": "The date cannot be in the future", + "NotificationLogs:All": "All", + "NotificationLogs:Created": "Created", + "NotificationLogs:Type": "Type", + "NotificationLogs:Severity": "Severity", + "NotificationLogs:Channel": "Channel", + "NotificationLogs:Title": "Title", + "NotificationLogs:Message": "Message", + "NotificationLogs:Tenant": "Tenant", + "NotificationLogs:User": "User", + "NotificationLogs:Source": "Source", + "NotificationLogs:CorrelationId": "Correlation Id", + "NotificationLogs:RealtimeDelivered": "Realtime Delivered", + "NotificationLogs:Actions": "Actions", + "NotificationLogs:ViewDetails": "View Details", + "NotificationLogs:DetailsLoadFailed": "Failed to load notification log details.", + "NotificationLogs:Yes": "Yes", + "NotificationLogs:No": "No", + "RealtimeOps:Title": "Unity Messaging", + "RealtimeOps:OnlineUsers": "Online Users", + "RealtimeOps:NoUsers": "No users are currently online.", + "RealtimeOps:Status": "Status", + "RealtimeOps:StatusActive": "Active (last 10 min)", + "RealtimeOps:StatusIdle": "Idle (last 30 min)", + "RealtimeOps:StatusAway": "Away (30+ min)", + "RealtimeOps:UserId": "User Id", + "RealtimeOps:UserName": "User Name", + "RealtimeOps:TenantId": "Tenant", + "RealtimeOps:Connections": "Connections", + "RealtimeOps:Activity": "Message Activity", + "RealtimeOps:Created": "Created", + "RealtimeOps:Direction": "Direction", + "RealtimeOps:Sent": "Sent", + "RealtimeOps:Received": "Received", + "RealtimeOps:SendMessage": "Send Message", + "RealtimeOps:Message": "Message", + "RealtimeOps:SendTo": "Send To", + "RealtimeOps:Send": "Send", + "RealtimeOps:SelectTarget": "Select a user or tenant before sending a message.", + "RealtimeOps:MessageSent": "Message sent.", + "RealtimeOps:MessageSendFailed": "Message could not be sent.", + "RealtimeOps:LoadUsersFailed": "Failed to load online users.", + "RealtimeOps:SendToTenant": "Send To Tenant", + "RealtimeOps:Users": "Users", + "RealtimeOps:Tenants": "Tenants", + "RealtimeWidget:Title": "Messages", + "RealtimeWidget:Empty": "No messages yet.", + "RealtimeWidget:To": "To...", + "RealtimeWidget:MessagePlaceholder": "Type a message...", + "RealtimeWidget:Send": "Send", + "RealtimeWidget:Reply": "Reply", + "RealtimeWidget:You": "You", + "RealtimeWidget:Individual": "Individual", + "RealtimeWidget:Tenant": "Team", + "RealtimeWidget:NoTenant": "No team available", + "RealtimeWidget:NoOnlineUsers": "No other users in your team", + "RealtimeWidget:StatusOffline": "Offline", + "RealtimeWidget:TenantBroadcast": "team broadcast" } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogChannel.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogChannel.cs new file mode 100644 index 0000000000..93933c46de --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogChannel.cs @@ -0,0 +1,9 @@ +namespace Unity.Notifications.Logs; + +public enum NotificationLogChannel +{ + SignalR = 0, + ExceptionPipeline = 1, + Prometheus = 2, + System = 3 +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogSeverity.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogSeverity.cs new file mode 100644 index 0000000000..a3af571003 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogSeverity.cs @@ -0,0 +1,9 @@ +namespace Unity.Notifications.Logs; + +public enum NotificationLogSeverity +{ + Info = 0, + Warning = 1, + Error = 2, + Critical = 3 +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogType.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogType.cs new file mode 100644 index 0000000000..37cb34533b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Logs/NotificationLogType.cs @@ -0,0 +1,15 @@ +namespace Unity.Notifications.Logs; + +public enum NotificationLogType +{ + SignalRSystemNotification = 0, + SignalRDirectMessage = 1, + SignalRGroupNotification = 2, + DbException = 3, + AbpHandledException = 4, + MiddlewareUnhandledException = 5, + PrometheusErrorCounterEvent = 6, + PrometheusExceptionCounterEvent = 7, + LegacyBridgeEvent = 8, + UnityException = 9 +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/IEmailLogsRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/IEmailLogsRepository.cs index 8ba3f48bc8..af1128ae19 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/IEmailLogsRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Emails/IEmailLogsRepository.cs @@ -9,5 +9,6 @@ public interface IEmailLogsRepository : IRepository { Task GetByIdAsync(Guid id, bool includeDetails = false); Task> GetByApplicationIdAsync(Guid applicationId); + Task> GetByApplicationIdsAndStatusAsync(List applicationIds, string status); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Logs/INotificationLogsRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Logs/INotificationLogsRepository.cs new file mode 100644 index 0000000000..82659eb510 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Logs/INotificationLogsRepository.cs @@ -0,0 +1,8 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Unity.Notifications.Logs; + +public interface INotificationLogsRepository : IRepository +{ +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Logs/NotificationLog.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Logs/NotificationLog.cs new file mode 100644 index 0000000000..11fe1c77c9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Logs/NotificationLog.cs @@ -0,0 +1,29 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.Notifications.Logs; + +public class NotificationLog : AuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid? UserId { get; set; } + public Guid? SenderUserId { get; set; } + public string? SenderDisplayName { get; set; } + public NotificationLogType NotificationType { get; set; } + public NotificationLogChannel Channel { get; set; } + public NotificationLogSeverity Severity { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string? SourceReference { get; set; } + public string? PayloadJson { get; set; } + public string? CorrelationId { get; set; } + public bool IsDeliveredRealtime { get; set; } + public string? DeliveryTarget { get; set; } + public string? ExceptionType { get; set; } + public string? ExceptionMessage { get; set; } + public string? StackExcerpt { get; set; } + public string? CommitSha { get; set; } + public string? Environment { get; set; } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDomainModule.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDomainModule.cs index d01c10075c..f69efcbb47 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDomainModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/NotificationsDomainModule.cs @@ -5,7 +5,7 @@ namespace Unity.Notifications; [DependsOn( typeof(AbpDddDomainModule), - typeof(NotificationsDomainSharedModule) + typeof(NotificationsDomainSharedModule) )] public class NotificationsDomainModule : AbpModule { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/INotificationReadStateRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/INotificationReadStateRepository.cs new file mode 100644 index 0000000000..649d6abb09 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/INotificationReadStateRepository.cs @@ -0,0 +1,8 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Unity.Notifications.ReadStates; + +public interface INotificationReadStateRepository : IRepository +{ +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/NotificationReadState.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/NotificationReadState.cs new file mode 100644 index 0000000000..91b93e654f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/NotificationReadState.cs @@ -0,0 +1,12 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.Notifications.ReadStates; + +public class NotificationReadState : AuditedAggregateRoot, IMultiTenant +{ + public Guid? TenantId { get; set; } + public Guid UserId { get; set; } + public DateTime LastReadAt { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/NotificationReadStateManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/NotificationReadStateManager.cs new file mode 100644 index 0000000000..5611752ea3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/ReadStates/NotificationReadStateManager.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Domain.Services; + +namespace Unity.Notifications.ReadStates; + +public class NotificationReadStateManager( + INotificationReadStateRepository notificationReadStateRepository) : DomainService +{ + public async Task GetLastReadAtAsync(Guid userId, Guid? tenantId) + { + var state = await FindAsync(userId, tenantId); + + return state?.LastReadAt ?? DateTime.MinValue; + } + + public async Task MarkReadAsync(Guid userId, Guid? tenantId) + { + var state = await FindAsync(userId, tenantId); + + if (state == null) + { + await notificationReadStateRepository.InsertAsync(new NotificationReadState + { + TenantId = tenantId, + UserId = userId, + LastReadAt = Clock.Now + }, autoSave: true); + } + else + { + state.LastReadAt = Clock.Now; + await notificationReadStateRepository.UpdateAsync(state, autoSave: true); + } + } + + private async Task FindAsync(Guid userId, Guid? tenantId) + { + var query = await notificationReadStateRepository.GetQueryableAsync(); + + return await AsyncExecuter.FirstOrDefaultAsync( + query.Where(x => x.UserId == userId && x.TenantId == tenantId)); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/INotificationsDbContext.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/INotificationsDbContext.cs index 0302d3abaa..40595d8743 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/INotificationsDbContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/INotificationsDbContext.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using Unity.Notifications.Emails; +using Unity.Notifications.Logs; +using Unity.Notifications.ReadStates; using Unity.Notifications.Templates; using Volo.Abp.Data; using Volo.Abp.EntityFrameworkCore; @@ -14,4 +16,6 @@ public interface INotificationsDbContext : IEfCoreDbContext public DbSet EmailLogAttachments { get; set; } public DbSet EmailTemplates { get; set; } public DbSet TemplateVariables { get; set; } + public DbSet NotificationLogs { get; set; } + public DbSet NotificationReadStates { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContext.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContext.cs index 55353fdc3e..3308f100c6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContext.cs @@ -1,5 +1,7 @@ using Microsoft.EntityFrameworkCore; using Unity.Notifications.Emails; +using Unity.Notifications.Logs; +using Unity.Notifications.ReadStates; using Unity.Notifications.Templates; using Unity.Notifications.EmailGroups; using Volo.Abp.Data; @@ -16,6 +18,8 @@ public class NotificationsDbContext : AbpDbContext, INot public DbSet TemplateVariables { get; set; } public DbSet EmailGroups { get; set; } public DbSet EmailGroupUsers { get; set; } + public DbSet NotificationLogs { get; set; } + public DbSet NotificationReadStates { get; set; } // Add DbSet for each Aggregate Root here. public NotificationsDbContext(DbContextOptions options) diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs index 8898ae6a8a..299e34409e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/NotificationsDbContextModelCreatingExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.EntityFrameworkCore; using Unity.Notifications.Emails; +using Unity.Notifications.Logs; +using Unity.Notifications.ReadStates; using Unity.Notifications.Templates; using Unity.Notifications.EmailGroups; using Volo.Abp; @@ -143,5 +145,40 @@ public static void ConfigureNotifications( .WithMany() .HasForeignKey(x => x.GroupId); }); + + modelBuilder.Entity(b => + { + b.ToTable(NotificationsDbProperties.DbTablePrefix + "NotificationLogs", NotificationsDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.NotificationType).HasConversion().HasMaxLength(64); + b.Property(x => x.Channel).HasConversion().HasMaxLength(32); + b.Property(x => x.Severity).HasConversion().HasMaxLength(32); + b.Property(x => x.Title).IsRequired().HasMaxLength(256); + b.Property(x => x.Message).IsRequired(); + b.Property(x => x.Source).IsRequired().HasMaxLength(200); + b.Property(x => x.SourceReference).HasMaxLength(256); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DeliveryTarget).HasMaxLength(256); + b.Property(x => x.ExceptionType).HasMaxLength(256); + b.Property(x => x.Environment).HasMaxLength(64); + b.Property(x => x.CommitSha).HasMaxLength(64); + b.Property(x => x.PayloadJson).HasColumnType("jsonb"); + b.Property(x => x.SenderDisplayName).HasMaxLength(256); + + b.HasIndex(x => new { x.TenantId, x.CreationTime }); + b.HasIndex(x => new { x.NotificationType, x.CreationTime }); + b.HasIndex(x => x.CorrelationId); + }); + + modelBuilder.Entity(b => + { + b.ToTable(NotificationsDbProperties.DbTablePrefix + "NotificationReadStates", NotificationsDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.HasIndex(x => new { x.TenantId, x.UserId }).IsUnique(); + }); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/EmailLogsRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/EmailLogsRepository.cs index ad5bafe3c1..a2f1181711 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/EmailLogsRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/EmailLogsRepository.cs @@ -15,18 +15,24 @@ public class EmailLogsRepository : EfCoreRepository dbContextProvider) : base(dbContextProvider) { - } - + } + public async Task GetByIdAsync(Guid id, bool includeDetails = false) { var dbSet = await GetDbSetAsync(); return await dbSet.FirstOrDefaultAsync(s => s.Id == id); - } - - public async Task> GetByApplicationIdAsync(Guid applicationId) - { - var dbSet = await GetDbSetAsync(); - return await dbSet.Where(x => x.ApplicationId == applicationId).ToListAsync(); + } + + public async Task> GetByApplicationIdAsync(Guid applicationId) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.Where(x => x.ApplicationId == applicationId).ToListAsync(); + } + + public async Task> GetByApplicationIdsAndStatusAsync(List applicationIds, string status) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.Where(x => applicationIds.Contains(x.ApplicationId) && x.Status == status).ToListAsync(); } public override async Task> WithDetailsAsync() diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/NotificationLogsRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/NotificationLogsRepository.cs new file mode 100644 index 0000000000..5825bb9253 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/NotificationLogsRepository.cs @@ -0,0 +1,12 @@ +using System; +using Unity.Notifications.EntityFrameworkCore; +using Unity.Notifications.Logs; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.Notifications.Repositories; + +public class NotificationLogsRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), INotificationLogsRepository +{ +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/NotificationReadStateRepository.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/NotificationReadStateRepository.cs new file mode 100644 index 0000000000..5f025004be --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Repositories/NotificationReadStateRepository.cs @@ -0,0 +1,12 @@ +using System; +using Unity.Notifications.EntityFrameworkCore; +using Unity.Notifications.ReadStates; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.Notifications.Repositories; + +public class NotificationReadStateRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), INotificationReadStateRepository +{ +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Bundling/NotificationsScriptBundleContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Bundling/NotificationsScriptBundleContributor.cs new file mode 100644 index 0000000000..2fb1de2a35 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Bundling/NotificationsScriptBundleContributor.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; + +namespace Unity.Notifications.Web.Bundling; + +public class NotificationsScriptBundleContributor : BundleContributor +{ + public override void ConfigureBundle(BundleConfigurationContext context) + { + context.Files.AddIfNotContains("/libs/signalr/browser/signalr.js"); + context.Files.AddIfNotContains("/js/notifications-realtime-client.js"); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Controllers/UnityMessagingController.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Controllers/UnityMessagingController.cs new file mode 100644 index 0000000000..cb1a6d2409 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Controllers/UnityMessagingController.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Unity.Modules.Shared.Permissions; +using Unity.Notifications.Localization; +using Unity.Notifications.Logs; +using Unity.Notifications.Features; +using Unity.Notifications.Web.Realtime; +using Volo.Abp; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Features; + +namespace Unity.Notifications.Web.Controllers; + +[RemoteService(true)] +[Authorize(IdentityConsts.ITOperationsPermissionName)] +[RequiresFeature(NotificationsFeatureConsts.DirectMessaging)] +[Route("api/notifications/unity-messaging")] +public class UnityMessagingController : AbpControllerBase +{ + private readonly IHubContext hubContext; + private readonly INotificationPresenceTracker presenceTracker; + private readonly INotificationLogsAppService notificationLogsAppService; + private readonly ICurrentTenant currentTenant; + + public UnityMessagingController( + IHubContext hubContext, + INotificationPresenceTracker presenceTracker, + INotificationLogsAppService notificationLogsAppService, + ICurrentTenant currentTenant) + { + this.hubContext = hubContext; + this.presenceTracker = presenceTracker; + this.notificationLogsAppService = notificationLogsAppService; + this.currentTenant = currentTenant; + LocalizationResource = typeof(NotificationsResource); + } + + [HttpGet("online-users")] + public ActionResult> GetOnlineUsers() + { + return Ok(presenceTracker.GetOnlineUsers()); + } + + [HttpPost("message-user")] + public async Task MessageUserAsync([FromBody] DirectMessageRequest request) + { + if (string.IsNullOrWhiteSpace(request.TargetUserId) || string.IsNullOrWhiteSpace(request.Message)) + { + return BadRequest("TargetUserId and Message are required."); + } + + var senderUserId = CurrentUser.Id?.ToString() ?? "unknown"; + var senderName = DisplayNameHelper.Resolve(CurrentUser); + + await hubContext.Clients.Group(NotificationHub.BuildUserGroup(request.TargetUserId)).SendAsync( + "directMessageReceived", + new + { + scope = "user", + source = nameof(UnityMessagingController), + senderId = senderUserId, + senderName, + message = request.Message, + timestamp = DateTime.UtcNow + }); + + var logId = await notificationLogsAppService.CreateAsync(new CreateNotificationLogDto + { + TenantId = currentTenant.Id, + UserId = Guid.TryParse(request.TargetUserId, out var targetUserId) ? targetUserId : null, + SenderUserId = CurrentUser.Id, + SenderDisplayName = senderName, + NotificationType = NotificationLogType.SignalRDirectMessage, + Channel = NotificationLogChannel.SignalR, + Severity = NotificationLogSeverity.Info, + Title = "Direct message sent", + Message = request.Message, + Source = nameof(UnityMessagingController), + CorrelationId = HttpContext.TraceIdentifier, + IsDeliveredRealtime = true, + DeliveryTarget = NotificationHub.BuildUserGroup(request.TargetUserId), + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }); + + return Ok(new { delivered = true, logId }); + } + + [HttpPost("message-tenant")] + public async Task MessageTenantAsync([FromBody] TenantMessageRequest request) + { + if (request.TargetTenantId == Guid.Empty || string.IsNullOrWhiteSpace(request.Message)) + { + return BadRequest("TargetTenantId and Message are required."); + } + + if (currentTenant.Id.HasValue && currentTenant.Id.Value != request.TargetTenantId) + { + return Forbid(); + } + + var senderUserId = CurrentUser.Id?.ToString() ?? "unknown"; + var senderName = DisplayNameHelper.Resolve(CurrentUser); + + await hubContext.Clients.Group(NotificationHub.BuildTenantGroup(request.TargetTenantId)).SendAsync( + "directMessageReceived", + new + { + scope = "tenant", + source = nameof(UnityMessagingController), + senderId = senderUserId, + senderName, + message = request.Message, + timestamp = DateTime.UtcNow + }); + + var logId = await notificationLogsAppService.CreateAsync(new CreateNotificationLogDto + { + TenantId = request.TargetTenantId, + SenderUserId = CurrentUser.Id, + SenderDisplayName = senderName, + NotificationType = NotificationLogType.SignalRDirectMessage, + Channel = NotificationLogChannel.SignalR, + Severity = NotificationLogSeverity.Info, + Title = "Tenant broadcast sent", + Message = request.Message, + Source = nameof(UnityMessagingController), + CorrelationId = HttpContext.TraceIdentifier, + IsDeliveredRealtime = true, + DeliveryTarget = NotificationHub.BuildTenantGroup(request.TargetTenantId), + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }); + + return Ok(new { delivered = true, logId }); + } +} + +public class DirectMessageRequest +{ + public string TargetUserId { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; +} + +public class TenantMessageRequest +{ + public Guid TargetTenantId { get; set; } + + public string Message { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationLogsUserMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationLogsUserMenuContributor.cs new file mode 100644 index 0000000000..f2be2107e4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationLogsUserMenuContributor.cs @@ -0,0 +1,47 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Unity.Modules.Shared.Permissions; +using Unity.Notifications.Localization; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; +using Volo.Abp.UI.Navigation; + +namespace Unity.Notifications.Web.Menus; + +public class NotificationLogsUserMenuContributor : IMenuContributor +{ + public async Task ConfigureMenuAsync(MenuConfigurationContext context) + { + if (context.Menu.Name != StandardMenus.User) + { + return; + } + + var featureChecker = context.ServiceProvider.GetRequiredService(); + + if (!await featureChecker.IsEnabledAsync("Unity.Notifications")) + { + return; + } + + var permissionChecker = context.ServiceProvider.GetRequiredService(); + + if (!await permissionChecker.IsGrantedAsync(IdentityConsts.ITOperationsPermissionName)) + { + return; + } + + var l = context.GetLocalizer(); + + context.Menu.AddItem( + new ApplicationMenuItem( + NotificationsMenus.NotificationLogs, + l["Menu:NotificationLogs"], + "~/NotificationLogs", + icon: "fl fl-table", + order: 90, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + ) + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenuContributor.cs index 1f01740952..26becb4ffe 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenuContributor.cs @@ -1,6 +1,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Unity.Modules.Shared.Permissions; using Unity.Notifications.Localization; +using Unity.Notifications.Features; using Unity.Notifications.Permissions; using Volo.Abp.Features; using Volo.Abp.UI.Navigation; @@ -13,7 +15,9 @@ public async Task ConfigureMenuAsync(MenuConfigurationContext context) { var featureChecker = context.ServiceProvider.GetRequiredService(); - if (await featureChecker.IsEnabledAsync("Unity.Notifications") && context.Menu.Name == StandardMenus.Main) + if (await featureChecker.IsEnabledAsync("Unity.Notifications") + && await featureChecker.IsEnabledAsync(NotificationsFeatureConsts.DirectMessaging) + && context.Menu.Name == StandardMenus.Main) { ConfigureMainMenu(context); } @@ -33,5 +37,27 @@ private static void ConfigureMainMenu(MenuConfigurationContext context) requiredPermissionName: NotificationsPermissions.NotificationList.View ) ); + + context.Menu.AddItem( + new ApplicationMenuItem( + NotificationsMenus.NotificationLogs, + l["Menu:NotificationLogs"], + "~/NotificationLogs", + icon: "fl fl-table", + order: 10, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + ) + ); + + context.Menu.AddItem( + new ApplicationMenuItem( + NotificationsMenus.UnityMessaging, + l["Menu:RealtimeOps"], + "~/UnityMessaging", + icon: "fl fl-users", + order: 11, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + ) + ); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenus.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenus.cs index 116621fbd7..9d5a6ae363 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenus.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Menus/NotificationsMenus.cs @@ -4,4 +4,6 @@ public static class NotificationsMenus { public const string Prefix = "Notifications"; public const string NotificationList = Prefix + ".NotificationList"; + public const string NotificationLogs = Prefix + ".NotificationLogs"; + public const string UnityMessaging = Prefix + ".UnityMessaging"; } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/NotificationsWebModule.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/NotificationsWebModule.cs index 556c6d76a1..9cd24d0dd5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/NotificationsWebModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/NotificationsWebModule.cs @@ -1,11 +1,15 @@ -using Microsoft.AspNetCore.Mvc.RazorPages; +using System; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Unity.Notifications.Localization; using Unity.Notifications.Web.Menus; +using Unity.Notifications.Web.Realtime; using Unity.Notifications.Web.Views.Settings; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.Localization; +using Volo.Abp.AspNetCore.SignalR; using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared; using Volo.Abp.Mapperly; using Volo.Abp.BackgroundJobs; @@ -22,11 +26,19 @@ namespace Unity.Notifications.Web; typeof(NotificationsApplicationModule), typeof(NotificationsApplicationContractsModule), typeof(AbpAspNetCoreMvcUiThemeSharedModule), + typeof(AbpAspNetCoreSignalRModule), typeof(AbpMapperlyModule), typeof(AbpSettingManagementWebModule) )] public class NotificationsWebModule : AbpModule { + private const string RedisPasswordKey = "Redis:Password"; + private const string RedisHostKey = "Redis:Host"; + private const string RedisPortKey = "Redis:Port"; + private const string RedisDefaultDatabaseKey = "Redis:DatabaseId"; + private const string RedisConfigurationKey = "Redis:Configuration"; + private const string RedisSentinelMasterNameKey = "Redis:SentinelMasterName"; + public override void PreConfigureServices(ServiceConfigurationContext context) { context.Services.PreConfigure(options => @@ -48,6 +60,19 @@ public override void PreConfigureServices(ServiceConfigurationContext context) public override void ConfigureServices(ServiceConfigurationContext context) { var configuration = context.Services.GetConfiguration(); + + var signalRBuilder = context.Services + .AddSignalR() + .AddHubOptions(options => + { + options.EnableDetailedErrors = false; + }); + + ConfigureSignalRRedisBackplane(signalRBuilder, configuration); + + context.Services.AddSingleton(); + context.Services.AddTransient(); + Configure(options => { options.IsJobExecutionEnabled = configuration.GetValue("BackgroundJobs:IsJobExecutionEnabled"); @@ -61,6 +86,7 @@ public override void ConfigureServices(ServiceConfigurationContext context) Configure(options => { options.MenuContributors.Add(new NotificationsMenuContributor()); + options.MenuContributors.Add(new NotificationLogsUserMenuContributor()); }); Configure(options => @@ -80,4 +106,78 @@ public override void ConfigureServices(ServiceConfigurationContext context) //Configure authorization. }); } + + private static void ConfigureSignalRRedisBackplane(ISignalRServerBuilder signalRBuilder, IConfiguration configuration) + { + if (!Convert.ToBoolean(configuration["Redis:IsEnabled"])) + { + return; + } + + var useSentinel = Convert.ToBoolean(configuration["Redis:UseSentinel"]); + var channelPrefix = configuration["Notifications:SignalR:ChannelPrefix"] ?? "unity:signalr"; + + if (useSentinel) + { + signalRBuilder.AddStackExchangeRedis(options => + { + options.Configuration = BuildSentinelOptions(configuration); + options.Configuration.ChannelPrefix = StackExchange.Redis.RedisChannel.Literal(channelPrefix); + }); + + return; + } + + var connectionString = BuildStandardConnectionString(configuration); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + return; + } + + signalRBuilder.AddStackExchangeRedis(connectionString, options => + { + options.Configuration.ChannelPrefix = StackExchange.Redis.RedisChannel.Literal(channelPrefix); + }); + } + + private static StackExchange.Redis.ConfigurationOptions BuildSentinelOptions(IConfiguration configuration) + { + var options = new StackExchange.Redis.ConfigurationOptions + { + ServiceName = configuration[RedisSentinelMasterNameKey], + Password = configuration[RedisPasswordKey], + AbortOnConnectFail = false, + AllowAdmin = true, + DefaultVersion = new Version(7, 0, 0), + DefaultDatabase = configuration.GetValue(RedisDefaultDatabaseKey, 0) + }; + + var endpoints = configuration[RedisConfigurationKey]?.Split(',', StringSplitOptions.RemoveEmptyEntries); + + if (endpoints != null) + { + foreach (var endpoint in endpoints) + { + options.EndPoints.Add(endpoint.Trim()); + } + } + + return options; + } + + private static string BuildStandardConnectionString(IConfiguration configuration) + { + var host = configuration[RedisHostKey]; + var port = configuration[RedisPortKey]; + var password = configuration[RedisPasswordKey]; + + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(port)) + { + return string.Empty; + } + + var passwordSegment = string.IsNullOrWhiteSpace(password) ? string.Empty : $",password={password}"; + return $"{host}:{port}{passwordSegment},abortConnect=false"; + } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.cshtml new file mode 100644 index 0000000000..2b0b96398f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.cshtml @@ -0,0 +1,96 @@ +@page +@using Microsoft.Extensions.Localization +@using Unity.Notifications.Localization +@model Unity.Notifications.Web.Pages.NotificationLogs.IndexModel +@inject IStringLocalizer L + +@section scripts { + + + +} + +
+
+
+ + + +
+ + +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.cshtml.cs new file mode 100644 index 0000000000..c3f10a4120 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.cshtml.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; + +namespace Unity.Notifications.Web.Pages.NotificationLogs; + +[Authorize(IdentityConsts.ITOperationsPermissionName)] +public class IndexModel : NotificationsPageModel +{ + public void OnGet() + { + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.js new file mode 100644 index 0000000000..8b809d31af --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/NotificationLogs/Index.js @@ -0,0 +1,344 @@ +$(function () { + const l = abp.localization.getResource('Notifications'); + const notificationsService = unity.notifications.logs.notificationLogsRead; + + const filters = { + dateFrom: null, + dateTo: null, + notificationType: null, + severity: null, + channel: null, + tenantId: null, + userId: null, + searchText: null + }; + + initializeTypeFilter(); + initializeDateRange(); + + const dt = $('#NotificationLogsTable'); + const dataTable = initializeDataTable({ + dt, + maxRowsPerPage: 20, + defaultSortColumn: { name: 'creationTime', dir: 'desc' }, + dataEndpoint: notificationsService.getList, + data: function () { + return { + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + notificationType: filters.notificationType, + severity: filters.severity, + channel: filters.channel, + tenantId: filters.tenantId, + userId: filters.userId, + searchText: filters.searchText + }; + }, + listColumns: getColumns(), + defaultVisibleColumns: [ + 'creationTime', + 'notificationType', + 'severity', + 'channel', + 'title', + 'message', + 'tenantId', + 'userId', + 'source', + 'correlationId', + 'isDeliveredRealtime' + ], + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dataTableName: 'NotificationLogsTable', + dynamicButtonContainerId: 'dynamicButtonContainerId', + useNullPlaceholder: true, + fixedHeaders: true + }); + + $('#search').on('input', function () { + filters.searchText = $(this).val() || null; + dataTable.ajax.reload(null, true); + }); + + $('#typeFilter').on('change', function () { + filters.notificationType = normalizeEnumFilter($(this).val()); + dataTable.ajax.reload(null, true); + }); + + $('#severityFilter').on('change', function () { + filters.severity = normalizeEnumFilter($(this).val()); + dataTable.ajax.reload(null, true); + }); + + $('#channelFilter').on('change', function () { + filters.channel = normalizeEnumFilter($(this).val()); + dataTable.ajax.reload(null, true); + }); + + $('#tenantFilter').on('input', function () { + filters.tenantId = normalizeGuidFilter($(this).val()); + dataTable.ajax.reload(null, true); + }); + + $('#userFilter').on('input', function () { + filters.userId = normalizeGuidFilter($(this).val()); + dataTable.ajax.reload(null, true); + }); + + $('#NotificationLogsTable').on('click', '.js-view-details', async function () { + const id = $(this).data('id'); + + try { + const detail = await notificationsService.get(id); + const payload = detail.payloadJson ? `\n\nPayload:\n${detail.payloadJson}` : ''; + + await abp.message.info( + `${detail.message || ''}${payload}`, + `${detail.notificationType} | ${detail.severity}` + ); + } catch (error) { + console.error(error); + abp.notify.error(l('NotificationLogs:DetailsLoadFailed')); + } + }); + + connectRealtime(dataTable); + + function initializeTypeFilter() { + const typeFilter = $('#typeFilter'); + const values = [ + { value: 0, text: 'SignalRSystemNotification' }, + { value: 1, text: 'SignalRDirectMessage' }, + { value: 2, text: 'SignalRGroupNotification' }, + { value: 3, text: 'DbException' }, + { value: 4, text: 'AbpHandledException' }, + { value: 5, text: 'MiddlewareUnhandledException' }, + { value: 6, text: 'PrometheusErrorCounterEvent' }, + { value: 7, text: 'PrometheusExceptionCounterEvent' }, + { value: 8, text: 'LegacyBridgeEvent' }, + { value: 9, text: 'UnityException' } + ]; + + values.forEach(function (item) { + typeFilter.append(``); + }); + } + + function initializeDateRange() { + const quickDateRange = $('#quickDateRange'); + const customDateInputs = $('#customDateInputs'); + const fromDateInput = $('#fromDate'); + const toDateInput = $('#toDate'); + + applyQuickDateRange('last6months'); + + quickDateRange.on('change', function () { + const range = $(this).val(); + if (range === 'custom') { + customDateInputs.show(); + return; + } + + customDateInputs.hide(); + applyQuickDateRange(range); + dataTable.ajax.reload(null, true); + }); + + fromDateInput.on('change', function () { + filters.dateFrom = $(this).val() || null; + quickDateRange.val('custom'); + customDateInputs.show(); + dataTable.ajax.reload(null, true); + }); + + toDateInput.on('change', function () { + filters.dateTo = $(this).val() || null; + quickDateRange.val('custom'); + customDateInputs.show(); + dataTable.ajax.reload(null, true); + }); + + function applyQuickDateRange(range) { + const today = new Date(); + const to = formatDate(today); + let from = null; + + switch (range) { + case 'today': + from = to; + break; + case 'last7days': + from = formatDate(addDays(today, -7)); + break; + case 'last30days': + from = formatDate(addDays(today, -30)); + break; + case 'last3months': + from = formatDate(addDays(today, -90)); + break; + case 'last6months': + from = formatDate(addDays(today, -180)); + break; + case 'alltime': + break; + } + + filters.dateFrom = from; + filters.dateTo = to; + + fromDateInput.val(from || ''); + toDateInput.val(to || ''); + } + } + + function getColumns() { + return [ + { + title: l('NotificationLogs:Created'), + name: 'creationTime', + data: 'creationTime', + className: 'data-table-header text-nowrap', + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + }, + { + title: l('NotificationLogs:Type'), + name: 'notificationType', + data: 'notificationType', + className: 'data-table-header text-nowrap' + }, + { + title: l('NotificationLogs:Severity'), + name: 'severity', + data: 'severity', + className: 'data-table-header text-nowrap' + }, + { + title: l('NotificationLogs:Channel'), + name: 'channel', + data: 'channel', + className: 'data-table-header text-nowrap' + }, + { + title: l('NotificationLogs:Title'), + name: 'title', + data: 'title', + className: 'data-table-header' + }, + { + title: l('NotificationLogs:Message'), + name: 'message', + data: 'message', + className: 'data-table-header', + render: function (data) { + const safe = $.fn.dataTable.render.text().display(data || ''); + return safe.length > 160 ? `${safe.substring(0, 160)}...` : safe; + } + }, + { + title: l('NotificationLogs:Tenant'), + name: 'tenantId', + data: 'tenantId', + className: 'data-table-header text-nowrap' + }, + { + title: l('NotificationLogs:User'), + name: 'userId', + data: 'userId', + className: 'data-table-header text-nowrap' + }, + { + title: l('NotificationLogs:Source'), + name: 'source', + data: 'source', + className: 'data-table-header text-nowrap' + }, + { + title: l('NotificationLogs:CorrelationId'), + name: 'correlationId', + data: 'correlationId', + className: 'data-table-header text-nowrap' + }, + { + title: l('NotificationLogs:RealtimeDelivered'), + name: 'isDeliveredRealtime', + data: 'isDeliveredRealtime', + className: 'data-table-header text-nowrap', + render: function (data) { + return data ? l('NotificationLogs:Yes') : l('NotificationLogs:No'); + } + }, + { + title: l('NotificationLogs:Actions'), + name: 'id', + data: 'id', + className: 'data-table-header text-nowrap', + render: function (data, type) { + if (type !== 'display') { + return data; + } + + const safeId = $.fn.dataTable.render.text().display(data || ''); + return ``; + } + } + ]; + } + +}); + +async function connectRealtime(table) { + if (typeof signalR === 'undefined') { + return; + } + + const connection = new signalR.HubConnectionBuilder() + .withUrl('/signalr/notifications') + .withAutomaticReconnect() + .build(); + + connection.on('notificationLogCreated', function () { + table.ajax.reload(null, false); + }); + + try { + await connection.start(); + } catch { + // Keep the page usable when realtime connection is temporarily unavailable. + } +} + +function normalizeEnumFilter(value) { + if (value === null || value === undefined || value === '') { + return null; + } + + return Number.parseInt(value, 10); +} + +function normalizeGuidFilter(value) { + const input = (value || '').trim(); + + if (!input) { + return null; + } + + const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return guidPattern.test(input) ? input : null; +} + +function addDays(date, days) { + const copy = new Date(date); + copy.setDate(copy.getDate() + days); + return copy; +} + +function formatDate(date) { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.cshtml new file mode 100644 index 0000000000..d308632181 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.cshtml @@ -0,0 +1,109 @@ +@page +@using Microsoft.Extensions.Localization +@using Unity.Notifications.Localization +@model Unity.Notifications.Web.Pages.UnityMessaging.IndexModel +@inject IStringLocalizer L + +@section styles { + + +} + +@section scripts { + + + + +} + +
+

@L["RealtimeOps:Title"]

+ +
+
+
+ @L["RealtimeOps:OnlineUsers"] + + @L["RealtimeOps:StatusActive"] + @L["RealtimeOps:StatusIdle"] + @L["RealtimeOps:StatusAway"] + +
+
+ + + + + + + + + + + + + +
@L["RealtimeOps:Status"]@L["RealtimeOps:UserName"]@L["RealtimeOps:UserId"]@L["RealtimeOps:TenantId"]@L["RealtimeOps:Connections"]
+
+
+ + + +
+
@L["RealtimeOps:SendMessage"]
+
+
+ + +
+
+
+ + +
+ + +
+
+ +
+ +
+
+
+ +
+
+ @L["RealtimeOps:Activity"] +
+ + + + +
+
+ +
+ +
+
+
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.cshtml.cs new file mode 100644 index 0000000000..f1c24f742f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.cshtml.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; +using Unity.Notifications.Features; +using Volo.Abp.Features; + +namespace Unity.Notifications.Web.Pages.UnityMessaging; + +[Authorize(IdentityConsts.ITOperationsPermissionName)] +[RequiresFeature(NotificationsFeatureConsts.DirectMessaging)] +public class IndexModel : NotificationsPageModel +{ + public void OnGet() + { + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.css new file mode 100644 index 0000000000..986b7104cc --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.css @@ -0,0 +1,118 @@ +.messaging-split-layout { + display: flex; + align-items: stretch; + gap: 1rem; + width: 100%; + margin-bottom: 1rem; + min-width: 0; +} + +.messaging-split-panel { + flex: 0 1 50%; + width: 50%; + min-width: 0; + margin-bottom: 0; + overflow: hidden; +} + +.messaging-split-panel .card-body { + min-width: 0; + overflow: auto; +} + +.messaging-split-divider { + width: 8px; + flex: 0 0 8px; + border-left: 1px solid #ced4da; + border-right: 1px solid #ced4da; + background: #dee2e6; + cursor: col-resize; + align-self: stretch; +} + +.messaging-split-divider:hover, +.messaging-split-divider:focus { + background: #adb5bd; + outline: none; +} + +.messaging-activity-card { + display: flex; + flex-direction: column; + min-height: 320px; + height: calc(100vh - 390px); + overflow: hidden; +} + +.messaging-activity-card > .card-header { + flex: 0 0 auto; +} + +.messaging-activity-card > .card-body { + flex: 0 0 auto; +} + +.messaging-activity-card > .card-body.p-0 { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messaging-activity-card #UnityMessagingActivityTable_wrapper, +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-container, +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-layout-table, +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-scroll { + min-height: 0; +} + +.messaging-activity-card #UnityMessagingActivityTable_wrapper, +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-container { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; +} + +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-layout-table, +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-scroll { + display: flex; + flex: 1 1 auto; + flex-direction: column; + overflow: hidden; +} + +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-scroll-body { + flex: 1 1 auto; + min-height: 0; + max-height: none !important; + overflow: auto !important; +} + +.messaging-activity-card #UnityMessagingActivityTable_wrapper .dt-layout-row:last-child { + flex: 0 0 auto; + min-height: 48px; + margin: 0; + padding: 0.5rem 0; + background: var(--bs-card-bg, #fff); +} + +@media (max-width: 992px) { + .messaging-split-layout { + flex-direction: column; + } + + .messaging-split-panel { + width: 100%; + } + + .messaging-split-divider { + display: none; + } + + .messaging-activity-card { + height: auto; + min-height: 420px; + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.js new file mode 100644 index 0000000000..2a2b366b3b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/UnityMessaging/Index.js @@ -0,0 +1,490 @@ +$(function () { + const l = abp.localization.getResource('Notifications'); + const usersTableBody = $('#OnlineUsersTable tbody'); + const sendTarget = $('#SendTarget'); + const sendTenantFilterContainer = $('#sendTenantFilterContainer'); + const sendTenantFilter = $('#SendTenantFilter'); + const messageInput = $('#MessageText'); + const messageModeTabs = $('.rt-widget-mode-tab[data-message-mode]'); + const messagingSplitLayout = document.getElementById('messagingSplitLayout'); + const messagingUsersPanel = document.getElementById('messagingUsersPanel'); + const messagingComposePanel = document.getElementById('messagingComposePanel'); + const messagingSplitDivider = document.getElementById('messagingSplitDivider'); + let messageMode = 'individual'; + let availableTenants = []; + const activityFilters = { + dateFrom: null, + dateTo: null, + searchText: null + }; + let tenantNamesById = {}; + + initializeMessagingSplit(); + + initializeActivityDateRange(); + const activityTable = initializeActivityTable(); + + $('#activitySearch').on('input', function () { + activityFilters.searchText = $(this).val() || null; + activityTable.ajax.reload(null, true); + }); + + messageModeTabs.on('click', function () { + setMessageMode($(this).data('message-mode')); + }); + + sendTenantFilter.on('change', function () { + sendTarget.val(''); + renderSendTargetOptions(); + }); + + let connection = null; + let lastKnownUsers = []; + const STATUS_GREEN_MS = 10 * 60 * 1000; + const STATUS_ORANGE_MS = 30 * 60 * 1000; + + initializeRealtime().then(() => { + loadOnlineUsers(); + }); + + loadTenants(); + + setInterval(() => renderUsers(lastKnownUsers), 30 * 1000); + + $('#SendButton').on('click', async function () { + const target = sendTarget.val(); + const message = (messageInput.val() || '').trim(); + + if (!target) { + abp.notify.warn(l('RealtimeOps:SelectTarget')); + return; + } + + if (!message) { + return; + } + + const [kind, id] = splitTarget(target); + + try { + if (kind === 'user') { + await abp.ajax({ + url: '/api/notifications/unity-messaging/message-user', + type: 'POST', + data: JSON.stringify({ targetUserId: id, message }), + contentType: 'application/json' + }); + } else if (kind === 'tenant') { + await abp.ajax({ + url: '/api/notifications/unity-messaging/message-tenant', + type: 'POST', + data: JSON.stringify({ targetTenantId: id, message }), + contentType: 'application/json' + }); + } else { + return; + } + + messageInput.val(''); + } catch (error) { + if (window.abp && abp.notify && typeof abp.notify.error === 'function') { + abp.notify.error(l('RealtimeOps:MessageSendFailed')); + } + } + }); + + usersTableBody.on('click', '.js-select-user', function () { + setMessageMode('individual', $(this).data('tenant-id')); + sendTarget.val(`user:${$(this).data('user-id')}`); + }); + + usersTableBody.on('click', '.js-select-tenant', function () { + setMessageMode('tenant'); + sendTarget.val(`tenant:${$(this).data('tenant-id')}`); + }); + + function setMessageMode(mode, tenantId) { + messageMode = mode === 'tenant' ? 'tenant' : 'individual'; + messageModeTabs.each(function () { + const isActive = $(this).data('message-mode') === messageMode; + $(this).toggleClass('active', isActive); + $(this).attr('aria-selected', isActive ? 'true' : 'false'); + }); + + sendTenantFilterContainer.toggle(messageMode === 'individual'); + if (messageMode === 'individual' && tenantId) { + sendTenantFilter.val(tenantId); + } + + renderSendTargetOptions(); + } + + function splitTarget(value) { + const separatorIndex = value.indexOf(':'); + if (separatorIndex === -1) { + return [null, null]; + } + return [value.slice(0, separatorIndex), value.slice(separatorIndex + 1)]; + } + + function initializeMessagingSplit() { + if (!messagingSplitLayout || !messagingUsersPanel || !messagingComposePanel || !messagingSplitDivider) { + return; + } + + const storageKey = 'UnityMessaging_SplitWidth'; + + function applyWidth(value) { + const percentage = Math.min(0.8, Math.max(0.2, value)); + messagingUsersPanel.style.flexBasis = `calc(${percentage * 100}% - 4px)`; + messagingComposePanel.style.flexBasis = `calc(${(1 - percentage) * 100}% - 4px)`; + localStorage.setItem(storageKey, String(percentage)); + } + + const savedWidth = Number.parseFloat(localStorage.getItem(storageKey)); + applyWidth(Number.isFinite(savedWidth) ? savedWidth : 0.5); + + let resizing = false; + messagingSplitDivider.addEventListener('pointerdown', function (event) { + event.preventDefault(); + resizing = true; + messagingSplitDivider.setPointerCapture(event.pointerId); + }); + + messagingSplitDivider.addEventListener('pointermove', function (event) { + if (!resizing) { + return; + } + + const bounds = messagingSplitLayout.getBoundingClientRect(); + const percentage = (event.clientX - bounds.left) / bounds.width; + applyWidth(percentage); + }); + + messagingSplitDivider.addEventListener('pointerup', function (event) { + resizing = false; + messagingSplitDivider.releasePointerCapture(event.pointerId); + }); + + messagingSplitDivider.addEventListener('keydown', function (event) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + event.preventDefault(); + const current = Number.parseFloat(localStorage.getItem(storageKey)) || 0.5; + applyWidth(current + (event.key === 'ArrowRight' ? 0.05 : -0.05)); + }); + } + + async function initializeRealtime() { + if (typeof signalR === 'undefined') { + return; + } + + connection = new signalR.HubConnectionBuilder() + .withUrl('/signalr/notifications') + .withAutomaticReconnect() + .build(); + + connection.on('onlineUsersUpdated', function (users) { + renderUsers(users || []); + }); + + connection.on('directMessageReceived', function (eventData) { + activityTable.ajax.reload(null, false); + }); + + try { + await connection.start(); + } catch (error) { + console.error('Unable to connect to the realtime messaging hub.', error); + } + } + + function initializeActivityTable() { + return initializeDataTable({ + dt: $('#UnityMessagingActivityTable'), + maxRowsPerPage: 20, + defaultSortColumn: { name: 'creationTime', dir: 'desc' }, + dataEndpoint: unity.notifications.logs.notificationLogsRead.getList, + data: function () { + return { + dateFrom: activityFilters.dateFrom, + dateTo: activityFilters.dateTo, + notificationType: 1, + searchText: activityFilters.searchText + }; + }, + listColumns: [ + { + title: l('RealtimeOps:Created'), + name: 'creationTime', + data: 'creationTime', + className: 'data-table-header text-nowrap', + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type, { + hour: '2-digit', + minute: '2-digit' + }); + } + }, + { + title: l('RealtimeOps:Direction'), + name: 'source', + data: 'source', + className: 'data-table-header text-nowrap', + render: function (data) { + return data === 'NotificationHub' || data === 'UnityMessagingController' + ? l('RealtimeOps:Sent') + : l('RealtimeOps:Received'); + } + }, + { + title: l('NotificationLogs:Message'), + name: 'message', + data: 'message', + className: 'data-table-header', + render: function (data) { + return $.fn.dataTable.render.text().display(data || ''); + } + }, + { + title: l('NotificationLogs:Tenant'), + name: 'tenantId', + data: 'tenantId', + className: 'data-table-header text-nowrap', + render: function (data) { + return $.fn.dataTable.render.text().display( + data ? (tenantNamesById[data] || data) : '' + ); + } + }, + { + title: l('NotificationLogs:User'), + name: 'userDisplayName', + data: 'userDisplayName', + className: 'data-table-header text-nowrap' + } + ], + defaultVisibleColumns: ['creationTime', 'source', 'message', 'tenantId', 'userDisplayName'], + actionButtons: [], + pagingEnabled: true, + reorderEnabled: false, + languageSetValues: {}, + dataTableName: 'UnityMessagingActivityTable', + useNullPlaceholder: true, + fixedHeaders: true + }); + } + + function initializeActivityDateRange() { + const quickDateRange = $('#activityQuickDateRange'); + const customDateInputs = $('#activityCustomDateInputs'); + const fromDateInput = $('#activityFromDate'); + const toDateInput = $('#activityToDate'); + + applyQuickDateRange('last6months'); + + quickDateRange.on('change', function () { + const range = $(this).val(); + if (range === 'custom') { + customDateInputs.show(); + return; + } + + customDateInputs.hide(); + applyQuickDateRange(range); + activityTable.ajax.reload(null, true); + }); + + fromDateInput.add(toDateInput).on('change', function () { + activityFilters.dateFrom = fromDateInput.val() || null; + activityFilters.dateTo = toDateInput.val() || null; + quickDateRange.val('custom'); + customDateInputs.show(); + activityTable.ajax.reload(null, true); + }); + + function applyQuickDateRange(range) { + const today = new Date(); + const to = formatDate(today); + let from = null; + + switch (range) { + case 'today': + from = to; + break; + case 'last7days': + from = formatDate(addDays(today, -7)); + break; + case 'last30days': + from = formatDate(addDays(today, -30)); + break; + case 'last3months': + from = formatDate(addDays(today, -90)); + break; + case 'last6months': + from = formatDate(addDays(today, -180)); + break; + case 'alltime': + break; + } + + activityFilters.dateFrom = from; + activityFilters.dateTo = to; + fromDateInput.val(from || ''); + toDateInput.val(to || ''); + } + + function addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } + + function formatDate(date) { + return date.toISOString().slice(0, 10); + } + } + + async function loadTenants() { + if (!window.unity?.tenantManagement?.tenant) { + return; + } + + try { + const result = await unity.tenantManagement.tenant.getList({ + maxResultCount: 1000 + }); + + const tenants = (result?.items || []) + .filter(t => t?.id) + .sort((a, b) => (a.name || '').localeCompare(b.name || '')); + + tenantNamesById = {}; + tenants.forEach(t => { tenantNamesById[t.id] = t.name || t.id; }); + availableTenants = tenants; + + renderTenantFilter(); + renderSendTargetOptions(); + renderUsers(lastKnownUsers); + activityTable.ajax.reload(null, false); + } catch { + // Tenant list is a convenience for the dropdown and tenant-name display; + // user-targeted messaging still works without it. + } + } + + function renderTenantFilter() { + const currentValue = sendTenantFilter.val(); + + sendTenantFilter.html(` + + ${availableTenants + .map(t => ``) + .join('')} + `); + + if (currentValue && availableTenants.some(t => t.id === currentValue)) { + sendTenantFilter.val(currentValue); + } + } + + function renderSendTargetOptions() { + const currentValue = sendTarget.val(); + const selectedTenantId = sendTenantFilter.val(); + + let options = ''; + if (messageMode === 'tenant') { + options = availableTenants + .map(t => ``) + .join('') + } else if (selectedTenantId) { + options = lastKnownUsers + .filter(u => !!u && !!u.userId && u.tenantId === selectedTenantId) + .map(u => ``) + .join(''); + } + + sendTarget.html(` + + ${options} + `); + + if (currentValue && sendTarget.find(`option[value="${currentValue}"]`).length) { + sendTarget.val(currentValue); + } + } + + async function loadOnlineUsers() { + try { + const users = await abp.ajax({ + url: '/api/notifications/unity-messaging/online-users', + type: 'GET' + }); + + renderUsers(users || []); + } catch { + abp.notify.error(l('RealtimeOps:LoadUsersFailed')); + } + } + + function renderUsers(users) { + lastKnownUsers = Array.isArray(users) ? users : []; + + if (lastKnownUsers.length === 0) { + usersTableBody.html(`${l('RealtimeOps:NoUsers')}`); + } else { + const rows = lastKnownUsers + .filter(u => !!u && !!u.userId) + .map(function (u) { + const userName = sanitizeHtml(u.userName || ''); + const userId = sanitizeHtml(u.userId || ''); + const tenantId = u.tenantId || ''; + const tenantName = sanitizeHtml(tenantId ? (tenantNamesById[tenantId] || tenantId) : ''); + const connectionCount = Number.parseInt(u.connectionCount || 0, 10); + const status = getActivityStatus(u.lastActivityUtc); + + return ` + + ${userName} + ${userId} + ${tenantName} + ${connectionCount} + + ${tenantId ? `` : ''} + `; + }) + .join(''); + + usersTableBody.html(rows); + } + + renderSendTargetOptions(); + } + + function getActivityStatus(lastActivityUtc) { + const lastActivity = lastActivityUtc ? new Date(lastActivityUtc) : null; + const ageMs = lastActivity && !Number.isNaN(lastActivity.getTime()) + ? Date.now() - lastActivity.getTime() + : Number.POSITIVE_INFINITY; + + if (ageMs <= STATUS_GREEN_MS) { + return { className: 'green', title: `${l('RealtimeOps:StatusActive')} (${lastActivity.toLocaleTimeString()})` }; + } + + if (ageMs <= STATUS_ORANGE_MS) { + return { className: 'orange', title: `${l('RealtimeOps:StatusIdle')} (${lastActivity.toLocaleTimeString()})` }; + } + + return { + className: 'red', + title: lastActivity ? `${l('RealtimeOps:StatusAway')} (${lastActivity.toLocaleTimeString()})` : l('RealtimeOps:StatusAway') + }; + } + + function sanitizeHtml(value) { + return $('
').text(value || '').html(); + } +}); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/DisplayNameHelper.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/DisplayNameHelper.cs new file mode 100644 index 0000000000..51d26ab72b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/DisplayNameHelper.cs @@ -0,0 +1,42 @@ +using System.Security.Claims; +using Volo.Abp.Identity; +using Volo.Abp.Security.Claims; +using Volo.Abp.Users; + +namespace Unity.Notifications.Web.Realtime; + +public static class DisplayNameHelper +{ + public static string Resolve(ICurrentUser currentUser) + { + return Combine(currentUser.SurName, currentUser.Name, currentUser.UserName); + } + + public static string Resolve(IdentityUser user) + { + return Combine(user.Surname, user.Name, user.UserName); + } + + public static string Resolve(ClaimsPrincipal? user) + { + var surname = user?.FindFirstValue(AbpClaimTypes.SurName); + var name = user?.FindFirstValue(AbpClaimTypes.Name); + var userName = user?.FindFirstValue(AbpClaimTypes.UserName) ?? user?.Identity?.Name; + + return Combine(surname, name, userName); + } + + private static string Combine(string? surname, string? name, string? userName) + { + var hasSurname = !string.IsNullOrWhiteSpace(surname); + var hasName = !string.IsNullOrWhiteSpace(name); + + return (hasSurname, hasName) switch + { + (true, true) => $"{surname}, {name}", + (false, true) => name!, + (true, false) => surname!, + _ => userName ?? "Unknown User" + }; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/INotificationPresenceTracker.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/INotificationPresenceTracker.cs new file mode 100644 index 0000000000..58d73f8fab --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/INotificationPresenceTracker.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace Unity.Notifications.Web.Realtime; + +public interface INotificationPresenceTracker +{ + void UserConnected(string connectionId, string userId, string userName, Guid? tenantId, bool isItOperations); + + void UserDisconnected(string connectionId); + + void Heartbeat(string connectionId); + + IReadOnlyList GetOnlineUsers(); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationHub.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationHub.cs new file mode 100644 index 0000000000..3940e149df --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationHub.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Unity.Modules.Shared.Permissions; +using Unity.Notifications.Logs; +using Unity.Notifications.Features; +using Unity.Notifications.ReadStates; +using Volo.Abp.AspNetCore.SignalR; +using Volo.Abp.Features; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; + +namespace Unity.Notifications.Web.Realtime; + +[Authorize] +[HubRoute(HubRoute)] +public class NotificationHub( + ICurrentTenant currentTenant, + INotificationPresenceTracker presenceTracker, + INotificationLogsAppService notificationLogsAppService, + IIdentityUserRepository identityUserRepository, + IFeatureChecker featureChecker, + INotificationLogsRepository notificationLogsRepository, + NotificationReadStateManager notificationReadStateManager) : Hub +{ + public const string HubRoute = "/signalr/notifications"; + public const string NotificationLogsOpsGroup = "ops:notification-logs"; + + public override async Task OnConnectedAsync() + { + if (!await featureChecker.IsEnabledAsync(NotificationsFeatureConsts.DirectMessaging)) + { + Context.Abort(); + return; + } + + var userId = GetCurrentUserId(); + var isItOperations = IsItOperationsUser(); + + if (!string.IsNullOrWhiteSpace(userId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, BuildUserGroup(userId)); + + var userName = DisplayNameHelper.Resolve(Context.User); + + presenceTracker.UserConnected( + Context.ConnectionId, + userId, + userName, + currentTenant.Id, + isItOperations); + } + + if (currentTenant.Id.HasValue) + { + await Groups.AddToGroupAsync(Context.ConnectionId, BuildTenantGroup(currentTenant.Id.Value)); + } + + if (isItOperations) + { + await Groups.AddToGroupAsync(Context.ConnectionId, NotificationLogsOpsGroup); + } + + await BroadcastOnlineUsersAsync(); + await BroadcastTenantPresenceAsync(); + + await base.OnConnectedAsync(); + } + + public async Task GetUnreadMessagesAsync() + { + if (!Guid.TryParse(GetCurrentUserId(), out var userId)) + { + return []; + } + + var lastReadAt = await notificationReadStateManager.GetLastReadAtAsync(userId, currentTenant.Id); + var query = await notificationLogsRepository.GetQueryableAsync(); + var messages = await query + .Where(x => x.NotificationType == NotificationLogType.SignalRDirectMessage + && x.CreationTime > lastReadAt + && (x.UserId == userId || (x.UserId == null && x.TenantId == currentTenant.Id))) + .OrderBy(x => x.CreationTime) + .Take(50) + .ToListAsync(); + + return [.. messages.Select(message => new UnreadMessageInfo + { + Scope = message.UserId.HasValue ? "user" : "tenant", + TargetId = message.UserId.HasValue ? userId.ToString() : message.TenantId?.ToString(), + SenderId = message.SenderUserId?.ToString(), + SenderName = message.SenderDisplayName ?? message.SenderUserId?.ToString() ?? "unknown", + Source = message.Source, + Message = message.Message, + Timestamp = message.CreationTime + })]; + } + + public Task MarkMessagesReadAsync() + { + return Guid.TryParse(GetCurrentUserId(), out var userId) + ? notificationReadStateManager.MarkReadAsync(userId, currentTenant.Id) + : Task.CompletedTask; + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + presenceTracker.UserDisconnected(Context.ConnectionId); + await BroadcastOnlineUsersAsync(); + await BroadcastTenantPresenceAsync(); + + await base.OnDisconnectedAsync(exception); + } + + public async Task HeartbeatAsync() + { + presenceTracker.Heartbeat(Context.ConnectionId); + + await BroadcastTenantPresenceAsync(); + } + + public Task GetOnlineUsersAsync() + { + if (!IsItOperationsUser()) + { + throw new HubException("Only ITOperations users can access online presence."); + } + + return Task.FromResult(presenceTracker.GetOnlineUsers().ToArray()); + } + + public async Task SendDirectMessageAsync(string targetUserId, string message) + { + if (!IsItOperationsUser()) + { + throw new HubException("Only ITOperations users can send direct messages."); + } + + if (string.IsNullOrWhiteSpace(targetUserId) || string.IsNullOrWhiteSpace(message)) + { + throw new HubException("Target user and message are required."); + } + + var senderId = GetCurrentUserId() ?? string.Empty; + var senderName = DisplayNameHelper.Resolve(Context.User); + var timestamp = DateTime.UtcNow; + + await Clients.Group(BuildUserGroup(targetUserId)).SendAsync("directMessageReceived", new + { + scope = "user", + source = nameof(NotificationHub), + senderId, + senderName, + message, + timestamp + }); + + await notificationLogsAppService.CreateAsync(new CreateNotificationLogDto + { + TenantId = currentTenant.Id, + UserId = Guid.TryParse(targetUserId, out var parsedTargetUserId) ? parsedTargetUserId : null, + SenderUserId = Guid.TryParse(senderId, out var parsedSenderUserId) ? parsedSenderUserId : null, + SenderDisplayName = senderName, + NotificationType = NotificationLogType.SignalRDirectMessage, + Channel = NotificationLogChannel.SignalR, + Severity = NotificationLogSeverity.Info, + Title = "Direct message sent", + Message = message, + Source = nameof(NotificationHub), + CorrelationId = Context.ConnectionId, + IsDeliveredRealtime = true, + DeliveryTarget = BuildUserGroup(targetUserId), + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }); + } + + public async Task GetTenantUsersAsync() + { + return await BuildTenantPresenceListAsync(); + } + + public Task GetCurrentTenantAsync() + { + return Task.FromResult(currentTenant.Id.HasValue + ? (object)new { id = currentTenant.Id, name = currentTenant.Name } + : null); + } + + public async Task SendTenantMessageAsync(string message) + { + if (!currentTenant.Id.HasValue || string.IsNullOrWhiteSpace(message)) + { + throw new HubException("A tenant and message are required."); + } + + var senderId = GetCurrentUserId() ?? string.Empty; + var senderName = DisplayNameHelper.Resolve(Context.User); + var timestamp = DateTime.UtcNow; + + await Clients.Group(BuildTenantGroup(currentTenant.Id.Value)).SendAsync("directMessageReceived", new + { + scope = "tenant", + source = nameof(NotificationHub), + tenantId = currentTenant.Id, + senderId, + senderName, + message, + timestamp + }); + + await notificationLogsAppService.CreateAsync(new CreateNotificationLogDto + { + TenantId = currentTenant.Id, + SenderUserId = Guid.TryParse(senderId, out var parsedSenderUserId) ? parsedSenderUserId : null, + SenderDisplayName = senderName, + NotificationType = NotificationLogType.SignalRDirectMessage, + Channel = NotificationLogChannel.SignalR, + Severity = NotificationLogSeverity.Info, + Title = "Tenant broadcast sent", + Message = message, + Source = nameof(NotificationHub), + CorrelationId = Context.ConnectionId, + IsDeliveredRealtime = true, + DeliveryTarget = BuildTenantGroup(currentTenant.Id.Value), + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }); + } + + public async Task SendPeerMessageAsync(string targetUserId, string message) + { + if (string.IsNullOrWhiteSpace(targetUserId) || string.IsNullOrWhiteSpace(message)) + { + throw new HubException("Target user and message are required."); + } + + if (!Guid.TryParse(targetUserId, out var targetUserGuid)) + { + throw new HubException("Target user is invalid."); + } + + // Tenant membership is enforced by ABP's automatic multi-tenancy filter on the repository query, + // so this returns null for users outside the current tenant even if the id is otherwise valid. + var targetUser = await identityUserRepository.FindAsync(targetUserGuid) ?? throw new HubException("Target user was not found in your tenant."); + var senderId = GetCurrentUserId() ?? string.Empty; + var senderName = DisplayNameHelper.Resolve(Context.User); + var timestamp = DateTime.UtcNow; + + await Clients.Group(BuildUserGroup(targetUserId)).SendAsync("directMessageReceived", new + { + scope = "user", + source = nameof(NotificationHub), + senderId, + senderName, + message, + timestamp + }); + + await notificationLogsAppService.CreateAsync(new CreateNotificationLogDto + { + TenantId = currentTenant.Id, + UserId = Guid.TryParse(targetUserId, out var parsedTargetUserId) ? parsedTargetUserId : null, + SenderUserId = Guid.TryParse(senderId, out var parsedSenderUserId) ? parsedSenderUserId : null, + SenderDisplayName = senderName, + NotificationType = NotificationLogType.SignalRDirectMessage, + Channel = NotificationLogChannel.SignalR, + Severity = NotificationLogSeverity.Info, + Title = "Peer message sent", + Message = message, + Source = nameof(NotificationHub), + CorrelationId = Context.ConnectionId, + IsDeliveredRealtime = true, + DeliveryTarget = BuildUserGroup(targetUserId), + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }); + } + + public static string BuildUserGroup(string userId) => $"user:{userId}"; + + public static string BuildTenantGroup(Guid tenantId) => $"tenant:{tenantId}"; + + public sealed class UnreadMessageInfo + { + public string Scope { get; set; } = string.Empty; + public string? TargetId { get; set; } + public string? SenderId { get; set; } + public string SenderName { get; set; } = string.Empty; + public string Source { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public DateTime Timestamp { get; set; } + } + + private Task BroadcastOnlineUsersAsync() + { + return Clients.Group(NotificationLogsOpsGroup) + .SendAsync("onlineUsersUpdated", presenceTracker.GetOnlineUsers()); + } + + private async Task BroadcastTenantPresenceAsync() + { + if (!currentTenant.Id.HasValue) + { + return; + } + + var presenceList = await BuildTenantPresenceListAsync(); + + await Clients.Group(BuildTenantGroup(currentTenant.Id.Value)) + .SendAsync("tenantPresenceUpdated", presenceList); + } + + private async Task BuildTenantPresenceListAsync() + { + var tenantUsers = await identityUserRepository.GetListAsync(maxResultCount: int.MaxValue); + + var onlineByUserId = presenceTracker.GetOnlineUsers() + .Where(u => u.TenantId == currentTenant.Id) + .ToDictionary(u => u.UserId); + + return [.. tenantUsers + .Select(user => + { + var userIdString = user.Id.ToString(); + var isOnline = onlineByUserId.TryGetValue(userIdString, out var online); + + return new TenantUserPresenceInfo + { + UserId = userIdString, + UserName = DisplayNameHelper.Resolve(user), + IsOnline = isOnline, + ConnectionCount = isOnline ? online!.ConnectionCount : 0, + LastActivityUtc = isOnline ? online!.LastActivityUtc : null + }; + }) + .OrderByDescending(x => x.IsOnline) + .ThenBy(x => x.UserName)]; + } + + private string? GetCurrentUserId() + { + return Context.User?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? Context.User?.FindFirstValue("sub") + ?? Context.User?.FindFirstValue("userid") + ?? Context.User?.FindFirstValue("user_id"); + } + + private bool IsItOperationsUser() + { + if (Context.User?.IsInRole(IdentityConsts.ITOperationsRoleName) == true) + { + return true; + } + + var user = Context.User; + + if (user == null) + { + return false; + } + + var roleClaimTypes = new[] { ClaimTypes.Role, "role", "roles" }; + + foreach (var type in roleClaimTypes) + { + if (user.Claims.Any(c => + string.Equals(c.Type, type, StringComparison.OrdinalIgnoreCase) + && string.Equals(c.Value, IdentityConsts.ITOperationsRoleName, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + + var realmAccessClaim = user.Claims.FirstOrDefault(c => + string.Equals(c.Type, "realm_access", StringComparison.OrdinalIgnoreCase)); + + if (realmAccessClaim == null || string.IsNullOrWhiteSpace(realmAccessClaim.Value)) + { + return false; + } + + try + { + using var json = JsonDocument.Parse(realmAccessClaim.Value); + + if (json.RootElement.TryGetProperty("roles", out var rolesElement) + && rolesElement.ValueKind == JsonValueKind.Array) + { + foreach (var role in rolesElement.EnumerateArray()) + { + if (role.ValueKind == JsonValueKind.String + && string.Equals(role.GetString(), IdentityConsts.ITOperationsRoleName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + catch + { + return false; + } + + return false; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationLogCreatedRealtimeHandler.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationLogCreatedRealtimeHandler.cs new file mode 100644 index 0000000000..00961c118b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationLogCreatedRealtimeHandler.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Unity.Notifications.Logs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus; +using Volo.Abp.EventBus.Local; + +namespace Unity.Notifications.Web.Realtime; + +public class NotificationLogCreatedRealtimeHandler(IHubContext hubContext) + : ILocalEventHandler, ITransientDependency +{ + public async Task HandleEventAsync(NotificationLogCreatedEto eventData) + { + await hubContext.Clients.Group(NotificationHub.NotificationLogsOpsGroup) + .SendAsync("notificationLogCreated", eventData); + + await hubContext.Clients.Group(NotificationHub.BuildTenantGroup(eventData.TenantId ?? Guid.Empty)) + .SendAsync("notificationLogCreated", eventData); + + if (eventData.UserId.HasValue) + { + await hubContext.Clients.Group(NotificationHub.BuildUserGroup(eventData.UserId.Value.ToString())) + .SendAsync("notificationLogCreated", eventData); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationPresenceTracker.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationPresenceTracker.cs new file mode 100644 index 0000000000..75a495f393 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/NotificationPresenceTracker.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Unity.Notifications.Web.Realtime; + +public class NotificationPresenceTracker : INotificationPresenceTracker +{ + private readonly ConcurrentDictionary connectionToUser = new(); + + private readonly ConcurrentDictionary users = new(); + + public void UserConnected(string connectionId, string userId, string userName, Guid? tenantId, bool isItOperations) + { + var presenceKey = BuildPresenceKey(userId, tenantId); + connectionToUser[connectionId] = presenceKey; + + var record = users.GetOrAdd(presenceKey, _ => new PresenceRecord + { + UserId = userId, + UserName = userName, + TenantId = tenantId, + IsItOperations = isItOperations + }); + + record.UserName = string.IsNullOrWhiteSpace(userName) ? record.UserName : userName; + record.TenantId = tenantId; + record.IsItOperations = isItOperations; + record.LastActivityUtc = DateTime.UtcNow; + record.Connections[connectionId] = 0; + } + + public void UserDisconnected(string connectionId) + { + if (!connectionToUser.TryRemove(connectionId, out var presenceKey)) + { + return; + } + + if (!users.TryGetValue(presenceKey, out var record)) + { + return; + } + + record.Connections.TryRemove(connectionId, out _); + + if (record.Connections.IsEmpty) + { + users.TryRemove(presenceKey, out _); + } + } + + public void Heartbeat(string connectionId) + { + if (!connectionToUser.TryGetValue(connectionId, out var presenceKey)) + { + return; + } + + if (users.TryGetValue(presenceKey, out var record)) + { + record.LastActivityUtc = DateTime.UtcNow; + } + } + + public IReadOnlyList GetOnlineUsers() + { + return users.Values + .Select(record => new OnlineUserInfo + { + UserId = record.UserId, + UserName = record.UserName, + TenantId = record.TenantId, + IsItOperations = record.IsItOperations, + ConnectionCount = record.Connections.Count, + LastActivityUtc = record.LastActivityUtc + }) + .OrderBy(x => x.UserName) + .ThenBy(x => x.UserId) + .ToList(); + } + + private static string BuildPresenceKey(string userId, Guid? tenantId) + { + return $"{tenantId?.ToString() ?? "host"}:{userId}"; + } + + private class PresenceRecord + { + public string UserId { get; set; } = string.Empty; + + public string UserName { get; set; } = string.Empty; + + public Guid? TenantId { get; set; } + + public bool IsItOperations { get; set; } + + public DateTime LastActivityUtc { get; set; } + + public ConcurrentDictionary Connections { get; } = new(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/OnlineUserInfo.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/OnlineUserInfo.cs new file mode 100644 index 0000000000..b3ee01c80c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/OnlineUserInfo.cs @@ -0,0 +1,18 @@ +using System; + +namespace Unity.Notifications.Web.Realtime; + +public class OnlineUserInfo +{ + public string UserId { get; set; } = string.Empty; + + public string UserName { get; set; } = string.Empty; + + public Guid? TenantId { get; set; } + + public bool IsItOperations { get; set; } + + public int ConnectionCount { get; set; } + + public DateTime LastActivityUtc { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/TenantUserPresenceInfo.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/TenantUserPresenceInfo.cs new file mode 100644 index 0000000000..f02a874e04 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Realtime/TenantUserPresenceInfo.cs @@ -0,0 +1,16 @@ +using System; + +namespace Unity.Notifications.Web.Realtime; + +public class TenantUserPresenceInfo +{ + public string UserId { get; set; } = string.Empty; + + public string UserName { get; set; } = string.Empty; + + public bool IsOnline { get; set; } + + public int ConnectionCount { get; set; } + + public DateTime? LastActivityUtc { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj index b5223b81ad..3e7aa2ba3b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj @@ -15,10 +15,14 @@ + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/css/notifications-realtime-widget.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/css/notifications-realtime-widget.css new file mode 100644 index 0000000000..0ba3c107fe --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/css/notifications-realtime-widget.css @@ -0,0 +1,291 @@ +.rt-widget-container { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 1080; +} + +.rt-widget-bubble { + position: relative; + width: 56px; + height: 56px; + border-radius: 50%; + border: none; + background-color: #2e5dd7; + color: #fff; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + cursor: pointer; + padding: 0; +} + +.rt-widget-bubble:hover { + background-color: #244bb0; +} + +.rt-widget-bounce { + animation: rt-widget-bounce 0.6s ease; +} + +@keyframes rt-widget-bounce { + 0%, 100% { transform: scale(1); } + 30% { transform: scale(1.15); } + 60% { transform: scale(0.95); } +} + +.rt-widget-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 20px; + height: 20px; + padding: 0 4px; + border-radius: 10px; + background-color: #d9534f; + color: #fff; + font-size: 11px; + line-height: 20px; + text-align: center; + box-sizing: border-box; +} + +.rt-widget-hidden { + display: none; +} + +.rt-widget-panel { + position: fixed; + right: 0; + bottom: 0; + width: 340px; + max-width: 100vw; + max-height: 480px; + background: #fff; + border: 1px solid #dee2e6; + border-bottom: none; + border-right: none; + border-radius: 8px 0 0 0; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.08); + display: none; + flex-direction: column; + overflow: hidden; +} + +.rt-widget-panel-open { + display: flex; +} + +.rt-widget-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background-color: #2e5dd7; + color: #fff; + font-weight: 600; + flex-shrink: 0; +} + +.rt-widget-close { + background: none; + border: none; + color: #fff; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0; +} + +.rt-widget-list { + overflow-y: auto; + padding: 8px 12px; + flex: 1 1 auto; + /* Roomy enough for ~5 typical messages; older ones stay reachable by scrolling. */ + max-height: 280px; +} + +.rt-widget-empty { + color: #495057; + text-align: center; + padding: 20px 0; + font-size: 0.9rem; +} + +.rt-widget-message { + padding: 8px 0; + border-bottom: 1px solid #eee; +} + +.rt-widget-message:last-child { + border-bottom: none; +} + +.rt-widget-message-meta { + display: flex; + justify-content: space-between; + font-size: 0.75rem; + color: #495057; + margin-bottom: 2px; +} + +.rt-widget-message-sender { + font-weight: 600; + color: #2e5dd7; +} + +.rt-widget-message-text { + font-size: 0.9rem; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.rt-widget-reply { + margin-top: 4px; + border: none; + background: none; + padding: 0; + color: #2e5dd7; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; +} + +.rt-widget-reply:hover { + text-decoration: underline; +} + +.rt-widget-compose { + flex-shrink: 0; + border-top: 1px solid #eee; + padding: 8px 12px; + background: #f8f9fb; +} + +.rt-widget-mode-tabs { + display: flex; + gap: 4px; + margin-bottom: 6px; +} + +.rt-widget-mode-tab { + flex: 1 1 50%; + border: 1px solid #ced4da; + background: #fff; + color: #495057; + padding: 5px 6px; + border-radius: 4px; + font-size: 0.8rem; + cursor: pointer; +} + +.rt-widget-mode-tab.active { + border-color: #2e5dd7; + background: #2e5dd7; + color: #fff; +} + +.rt-widget-target-row { + margin-bottom: 6px; +} + +.rt-widget-target-control { + position: relative; + width: 100%; +} + +.rt-widget-target-status { + position: absolute; + left: 8px; + top: 50%; + z-index: 1; + transform: translateY(-50%); + pointer-events: none; +} + +.rt-widget-compose-target { + width: 100%; + min-width: 0; + font-size: 0.85rem; + padding: 4px 6px 4px 25px; + border: 1px solid #ced4da; + border-radius: 4px; +} + +.rt-widget-compose-row { + display: flex; + gap: 6px; +} + +.rt-widget-compose-input { + flex: 1 1 auto; + min-width: 0; + font-size: 0.85rem; + padding: 6px 8px; + border: 1px solid #ced4da; + border-radius: 4px; +} + +.rt-widget-compose-send { + flex-shrink: 0; + border: none; + background-color: #2e5dd7; + color: #fff; + padding: 6px 12px; + border-radius: 4px; + font-size: 0.85rem; + cursor: pointer; +} + +.rt-widget-compose-send:hover { + background-color: #244bb0; +} + +@media (max-width: 480px) { + .rt-widget-panel { + width: 100vw; + } +} + +.rt-status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 4px; + vertical-align: middle; +} + +.rt-status-green { + background-color: #28a745; +} + +.rt-status-orange { + background-color: #fd7e14; +} + +.rt-status-red { + background-color: #dc3545; +} + +.rt-status-offline { + background-color: #adb5bd; +} + +.rt-status-hidden { + visibility: hidden; +} + +.rt-status-legend { + font-size: 0.8rem; + color: #495057; +} + +.rt-status-legend .rt-status-dot { + margin-left: 10px; +} + +.rt-status-legend .rt-status-dot:first-child { + margin-left: 0; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/js/notifications-realtime-client.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/js/notifications-realtime-client.js new file mode 100644 index 0000000000..83f407ee61 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/wwwroot/js/notifications-realtime-client.js @@ -0,0 +1,1125 @@ +(function () { + whenReady(init); + + function whenReady(callback) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback); + } else { + callback(); + } + } + + function init() { + if (/\/(account\/login|login|splash)(?:\/|$)/i.test(window.location.pathname)) { + return; + } + + if (window.location.pathname === '/') { + return; + } + + if (abp.features && typeof abp.features.isEnabled === 'function' + && !abp.features.isEnabled('Unity.Notifications.DirectMessaging')) { + return; + } + + if (typeof signalR === 'undefined') { + return; + } + + if (!window.abp?.currentUser?.isAuthenticated) { + return; + } + + const l = window.abp?.localization?.getResource?.('Notifications') + || function (key) { return key; }; + + const myUserId = abp.currentUser.id || null; + + let unreadCount = 0; + let panelOpen = false; + let peers = []; + let activeMode = 'individual'; + let currentTenant = null; + const histories = {}; + const MAX_HISTORY_ITEMS = 100; + const STATUS_GREEN_MS = 10 * 60 * 1000; + const STATUS_ORANGE_MS = 30 * 60 * 1000; + const widget = buildWidget(); + + const connection = new signalR.HubConnectionBuilder() + .withUrl('/signalr/notifications') + .withAutomaticReconnect() + .build(); + + connection.on('directMessageReceived', function (eventData) { + const scope = eventData?.scope || 'user'; + const sender = eventData?.senderName || eventData?.senderId || 'unknown'; + const senderId = eventData?.senderId; + const message = eventData?.message || ''; + const targetId = scope === 'tenant' + ? eventData?.tenantId || currentTenant?.id + : senderId; + + if (!targetId) { + return; + } + + const mode = scope === 'tenant' ? 'tenant' : 'individual'; + addMessage(mode, targetId, sender, senderId, message, eventData?.timestamp); + + if (scope === 'user') { + activeMode = 'individual'; + ensurePeerOption(senderId, sender); + } else { + activeMode = 'tenant'; + } + + widget.modeTabs.forEach(tab => tab.classList.toggle('active', tab.dataset.mode === activeMode)); + renderTargetOptions(); + widget.target.value = targetId; + renderConversation(); + + if (eventData.source === 'UnityMessagingController' && senderId && senderId !== myUserId) { + showIncomingToast(sender, message); + } + + }); + + connection.on('tenantPresenceUpdated', function (result) { + applyPeers(result); + }); + + const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; + const ACTIVITY_HEARTBEAT_THROTTLE_MS = 60 * 1000; + const ACTIVITY_EVENTS = ['click', 'keydown', 'mousemove', 'scroll']; + let lastHeartbeatSentAt = 0; + + connection.start().then(function () { + sendHeartbeat(true); + refreshPeers(); + refreshTenant(); + refreshUnreadMessages(); + setInterval(function () { sendHeartbeat(true); }, HEARTBEAT_INTERVAL_MS); + + ACTIVITY_EVENTS.forEach(function (eventName) { + document.addEventListener(eventName, function () { sendHeartbeat(false); }, { passive: true }); + }); + }).catch(function () { + // Keep normal page flow even when realtime connection is unavailable. + }); + + function refreshUnreadMessages() { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + connection.invoke('GetUnreadMessagesAsync').then(function (messages) { + (Array.isArray(messages) ? messages : []).forEach(function (eventData) { + const scope = eventData.scope === 'tenant' ? 'tenant' : 'user'; + const senderId = eventData.senderId || null; + const targetId = scope === 'tenant' ? eventData.targetId : senderId; + + if (!targetId) { + return; + } + + addMessage( + scope === 'tenant' ? 'tenant' : 'individual', + targetId, + eventData.senderName || senderId || 'unknown', + senderId, + eventData.message || '', + eventData.timestamp + ); + + if (scope === 'user') { + ensurePeerOption(senderId, eventData.senderName || senderId); + } + + if (eventData.source === 'UnityMessagingController' && senderId && senderId !== myUserId) { + showIncomingToast(eventData.senderName || senderId, eventData.message || ''); + } + }); + }).catch(function () { + // Unread history is a convenience; realtime delivery remains available. + }); + } + + function sendHeartbeat(force) { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + const now = Date.now(); + if (!force && now - lastHeartbeatSentAt < ACTIVITY_HEARTBEAT_THROTTLE_MS) { + return; + } + + lastHeartbeatSentAt = now; + connection.invoke('HeartbeatAsync').catch(function () { + // Ignore heartbeat failures; presence will simply age out. + }); + } + + function refreshPeers() { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + connection.invoke('GetTenantUsersAsync').then(function (result) { + applyPeers(result); + }).catch(function () { + // Peer list is a convenience for the compose "To" selector. + }); + } + + function applyPeers(result) { + peers = (Array.isArray(result) ? result : []).filter(function (p) { + return p?.userId && p.userId !== myUserId; + }); + renderTargetOptions(); + } + + function refreshTenant() { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + connection.invoke('GetCurrentTenantAsync').then(function (result) { + currentTenant = result || null; + renderTargetOptions(); + }).catch(function () { + currentTenant = null; + }); + } + + function renderTargetOptions() { + const currentValue = widget.target.value; + + if (activeMode === 'tenant') { + widget.targetControl.style.display = 'none'; + widget.target.innerHTML = currentTenant + ? `` + : ``; + widget.target.disabled = !currentTenant; + if (currentTenant) { + widget.target.value = currentTenant.id; + } + updateTargetStatusDot(); + renderConversation(); + return; + } + + widget.targetControl.style.display = ''; + + if (peers.length === 0) { + widget.target.innerHTML = ``; + widget.target.disabled = true; + updateTargetStatusDot(); + return; + } + + widget.target.disabled = false; + widget.target.innerHTML = `` + peers + .map(function (p) { + const suffix = p.isOnline ? '' : ` (${l('RealtimeWidget:StatusOffline')})`; + return ``; + }) + .join(''); + + if (currentValue && peers.some(function (p) { return p.userId === currentValue; })) { + widget.target.value = currentValue; + } + + updateTargetStatusDot(); + renderConversation(); + } + + function getPeerStatus(peer) { + if (!peer?.isOnline) { + return { className: 'offline', label: l('RealtimeWidget:StatusOffline') }; + } + + const lastActivity = peer.lastActivityUtc ? new Date(peer.lastActivityUtc) : null; + const ageMs = lastActivity && !Number.isNaN(lastActivity.getTime()) + ? Date.now() - lastActivity.getTime() + : Number.POSITIVE_INFINITY; + + if (ageMs <= STATUS_GREEN_MS) { + return { className: 'green', label: l('RealtimeOps:StatusActive') }; + } + + if (ageMs <= STATUS_ORANGE_MS) { + return { className: 'orange', label: l('RealtimeOps:StatusIdle') }; + } + + return { className: 'red', label: l('RealtimeOps:StatusAway') }; + } + + function updateTargetStatusDot() { + if (activeMode === 'tenant') { + widget.targetStatus.className = 'rt-status-dot rt-widget-target-status rt-status-hidden'; + return; + } + + const peer = peers.find(function (p) { return p.userId === widget.target.value; }); + const status = getPeerStatus(peer); + widget.targetStatus.className = `rt-status-dot rt-widget-target-status rt-status-${status.className}`; + widget.targetStatus.title = status.label; + } + + widget.composeSend.addEventListener('click', sendComposeMessage); + widget.target.addEventListener('change', function () { + updateTargetStatusDot(); + renderConversation(); + }); + widget.modeTabs.forEach(function (tab) { + tab.addEventListener('click', function () { + activeMode = tab.dataset.mode; + widget.modeTabs.forEach(item => item.classList.toggle('active', item === tab)); + renderTargetOptions(); + }); + }); + widget.composeInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendComposeMessage(); + } + }); + + function sendComposeMessage() { + const targetId = widget.target.value; + const message = widget.composeInput.value.trim(); + + if (!targetId || !message) { + return; + } + + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + const sendTask = activeMode === 'tenant' + ? connection.invoke('SendTenantMessageAsync', message) + : connection.invoke('SendPeerMessageAsync', targetId, message); + + sendTask.then(function () { + if (activeMode !== 'tenant') { + addMessage(activeMode, targetId, l('RealtimeWidget:You'), null, message, new Date().toISOString()); + } + widget.composeInput.value = ''; + }).catch(function () { + // Recipient is invalid or outside the tenant; leave the draft in place. + }); + } + + function addMessage(mode, targetId, sender, senderId, message, timestamp) { + const key = `${mode}:${targetId}`; + histories[key] = histories[key] || []; + histories[key].push({ sender, senderId, message, timestamp, mode }); + histories[key] = histories[key].slice(-MAX_HISTORY_ITEMS); + + if (!panelOpen) { + unreadCount += 1; + updateBadge(); + widget.bubble.classList.remove('rt-widget-bounce'); + widget.bubble.getBoundingClientRect(); + widget.bubble.classList.add('rt-widget-bounce'); + } + + renderConversation(); + } + + function ensurePeerOption(userId, userName) { + if (!userId) { + return; + } + + const existing = peers.find(peer => peer.userId === userId); + + if (existing) { + existing.isOnline = true; + existing.lastActivityUtc = new Date().toISOString(); + } else { + peers.push({ userId, userName, isOnline: true, connectionCount: 1, lastActivityUtc: new Date().toISOString() }); + } + } + + function renderConversation() { + const targetId = widget.target.value; + const items = targetId ? histories[`${activeMode}:${targetId}`] || [] : []; + widget.list.innerHTML = ''; + + if (items.length === 0) { + const empty = document.createElement('div'); + empty.className = 'rt-widget-empty'; + empty.textContent = l('RealtimeWidget:Empty'); + widget.list.appendChild(empty); + return; + } + + items.forEach(function (entry) { + const item = document.createElement('div'); + item.className = 'rt-widget-message' + (entry.mode === 'tenant' ? ' rt-widget-message-alert' : ''); + + const meta = document.createElement('div'); + meta.className = 'rt-widget-message-meta'; + const senderEl = document.createElement('span'); + senderEl.className = 'rt-widget-message-sender'; + senderEl.textContent = entry.mode === 'tenant' + ? `${entry.sender} (${l('RealtimeWidget:TenantBroadcast')})` + : entry.sender; + const timeEl = document.createElement('span'); + timeEl.className = 'rt-widget-message-time'; + timeEl.textContent = formatTime(entry.timestamp); + meta.appendChild(senderEl); + meta.appendChild(timeEl); + + const textEl = document.createElement('div'); + textEl.className = 'rt-widget-message-text'; + textEl.textContent = entry.message; + item.appendChild(meta); + item.appendChild(textEl); + widget.list.appendChild(item); + }); + + widget.list.scrollTop = widget.list.scrollHeight; + } + + function formatTime(value) { + const date = value ? new Date(value) : new Date(); + return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString(); + } + + function updateBadge() { + if (unreadCount > 0) { + widget.badge.textContent = unreadCount > 9 ? '9+' : String(unreadCount); + widget.badge.classList.remove('rt-widget-hidden'); + } else { + widget.badge.classList.add('rt-widget-hidden'); + } + } + + function showIncomingToast(sender, message) { + if (typeof Swal !== 'undefined') { + Swal.fire({ + toast: true, + position: 'top-end', + icon: 'info', + title: sender, + text: message, + showConfirmButton: false, + timer: 5000, + timerProgressBar: true + }); + return; + } + + if (window.abp?.notify?.info && typeof window.abp.notify.info === 'function') { + abp.notify.info(`${sender}: ${message}`); + } + } + + function togglePanel() { + panelOpen = !panelOpen; + widget.panel.classList.toggle('rt-widget-panel-open', panelOpen); + widget.bubble.classList.toggle('rt-widget-hidden', panelOpen); + + if (panelOpen) { + unreadCount = 0; + updateBadge(); + connection.invoke('MarkMessagesReadAsync').catch(function () { + // Ignore read-state failures; messages remain available in the activity log. + }); + refreshPeers(); + refreshTenant(); + } + } + + function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value || ''; + return div.innerHTML; + } + + function escapeAttribute(value) { + return escapeHtml(value).replaceAll('"', '"'); + } + + function buildWidget() { + const container = document.createElement('div'); + container.className = 'rt-widget-container'; + + const panel = document.createElement('div'); + panel.className = 'rt-widget-panel'; + + const header = document.createElement('div'); + header.className = 'rt-widget-panel-header'; + + const titleEl = document.createElement('span'); + titleEl.textContent = l('RealtimeWidget:Title'); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'rt-widget-close'; + closeBtn.setAttribute('aria-label', 'Close'); + closeBtn.innerHTML = '×'; + closeBtn.addEventListener('click', togglePanel); + + header.appendChild(titleEl); + header.appendChild(closeBtn); + + const list = document.createElement('div'); + list.className = 'rt-widget-list'; + + const empty = document.createElement('div'); + empty.className = 'rt-widget-empty'; + empty.textContent = l('RealtimeWidget:Empty'); + list.appendChild(empty); + + const compose = document.createElement('div'); + compose.className = 'rt-widget-compose'; + + const modeTabs = document.createElement('div'); + modeTabs.className = 'rt-widget-mode-tabs'; + + const individualTab = document.createElement('button'); + individualTab.type = 'button'; + individualTab.className = 'rt-widget-mode-tab active'; + individualTab.dataset.mode = 'individual'; + individualTab.textContent = l('RealtimeWidget:Individual'); + + const tenantTab = document.createElement('button'); + tenantTab.type = 'button'; + tenantTab.className = 'rt-widget-mode-tab'; + tenantTab.dataset.mode = 'tenant'; + tenantTab.textContent = l('RealtimeWidget:Tenant'); + + modeTabs.appendChild(individualTab); + modeTabs.appendChild(tenantTab); + + const targetRow = document.createElement('div'); + targetRow.className = 'rt-widget-target-row'; + + const targetControl = document.createElement('div'); + targetControl.className = 'rt-widget-target-control'; + + const targetStatus = document.createElement('span'); + targetStatus.className = 'rt-status-dot rt-widget-target-status rt-status-hidden'; + + const target = document.createElement('select'); + target.className = 'rt-widget-compose-target'; + target.innerHTML = ``; + + targetControl.appendChild(targetStatus); + targetControl.appendChild(target); + targetRow.appendChild(targetControl); + + const composeRow = document.createElement('div'); + composeRow.className = 'rt-widget-compose-row'; + + const composeInput = document.createElement('input'); + composeInput.type = 'text'; + composeInput.className = 'rt-widget-compose-input'; + composeInput.placeholder = l('RealtimeWidget:MessagePlaceholder'); + + const composeSend = document.createElement('button'); + composeSend.type = 'button'; + composeSend.className = 'rt-widget-compose-send'; + composeSend.textContent = l('RealtimeWidget:Send'); + + composeRow.appendChild(composeInput); + composeRow.appendChild(composeSend); + compose.appendChild(modeTabs); + compose.appendChild(targetRow); + compose.appendChild(composeRow); + + panel.appendChild(header); + panel.appendChild(list); + panel.appendChild(compose); + + const bubble = document.createElement('button'); + bubble.type = 'button'; + bubble.className = 'rt-widget-bubble'; + bubble.setAttribute('aria-label', l('RealtimeWidget:Title')); + bubble.innerHTML = ''; + bubble.addEventListener('click', togglePanel); + + const badge = document.createElement('span'); + badge.className = 'rt-widget-badge rt-widget-hidden'; + bubble.appendChild(badge); + + container.appendChild(panel); + container.appendChild(bubble); + document.body.appendChild(container); + + return { container, panel, list, bubble, badge, modeTabs: [individualTab, tenantTab], targetControl, target, targetStatus, composeInput, composeSend }; + } + } +})(); + +(function () { + whenReady(init); + + function whenReady(callback) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback); + } else { + callback(); + } + } + + function init() { + if (/\/(account\/login|login|splash)(?:\/|$)/i.test(window.location.pathname)) { + return; + } + + if (window.location.pathname === '/') { + return; + } + + if (abp.features && typeof abp.features.isEnabled === 'function' + && !abp.features.isEnabled('Unity.Notifications.DirectMessaging')) { + return; + } + + if (typeof signalR === 'undefined') { + return; + } + + if (!window.abp?.currentUser?.isAuthenticated) { + return; + } + + const l = window.abp?.localization?.getResource?.('Notifications') + || function (key) { return key; }; + + const myUserId = abp.currentUser.id || null; + + let unreadCount = 0; + let panelOpen = false; + let peers = []; + let activeMode = 'individual'; + let currentTenant = null; + const histories = {}; + const MAX_HISTORY_ITEMS = 100; + const STATUS_GREEN_MS = 10 * 60 * 1000; + const STATUS_ORANGE_MS = 30 * 60 * 1000; + const widget = buildWidget(); + + const connection = new signalR.HubConnectionBuilder() + .withUrl('/signalr/notifications') + .withAutomaticReconnect() + .build(); + + connection.on('directMessageReceived', function (eventData) { + const scope = eventData?.scope || 'user'; + const sender = eventData?.senderName || eventData?.senderId || 'unknown'; + const senderId = eventData?.senderId; + const message = eventData?.message || ''; + const targetId = scope === 'tenant' + ? eventData?.tenantId || currentTenant?.id + : senderId; + + if (!targetId) { + return; + } + + const mode = scope === 'tenant' ? 'tenant' : 'individual'; + addMessage(mode, targetId, sender, senderId, message, eventData?.timestamp); + + if (scope === 'user') { + activeMode = 'individual'; + ensurePeerOption(senderId, sender); + } else { + activeMode = 'tenant'; + } + + widget.modeTabs.forEach(tab => tab.classList.toggle('active', tab.dataset.mode === activeMode)); + renderTargetOptions(); + widget.target.value = targetId; + renderConversation(); + + if (eventData.source === 'UnityMessagingController' && senderId && senderId !== myUserId) { + showIncomingToast(sender, message); + } + + }); + + connection.on('tenantPresenceUpdated', function (result) { + applyPeers(result); + }); + + const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; + const ACTIVITY_HEARTBEAT_THROTTLE_MS = 60 * 1000; + const ACTIVITY_EVENTS = ['click', 'keydown', 'mousemove', 'scroll']; + let lastHeartbeatSentAt = 0; + + connection.start().then(function () { + sendHeartbeat(true); + refreshPeers(); + refreshTenant(); + refreshUnreadMessages(); + setInterval(function () { sendHeartbeat(true); }, HEARTBEAT_INTERVAL_MS); + + ACTIVITY_EVENTS.forEach(function (eventName) { + document.addEventListener(eventName, function () { sendHeartbeat(false); }, { passive: true }); + }); + }).catch(function () { + // Keep normal page flow even when realtime connection is unavailable. + }); + + function refreshUnreadMessages() { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + connection.invoke('GetUnreadMessagesAsync').then(function (messages) { + (Array.isArray(messages) ? messages : []).forEach(function (eventData) { + const scope = eventData.scope === 'tenant' ? 'tenant' : 'user'; + const senderId = eventData.senderId || null; + const targetId = scope === 'tenant' ? eventData.targetId : senderId; + + if (!targetId) { + return; + } + + addMessage( + scope === 'tenant' ? 'tenant' : 'individual', + targetId, + eventData.senderName || senderId || 'unknown', + senderId, + eventData.message || '', + eventData.timestamp + ); + + if (scope === 'user') { + ensurePeerOption(senderId, eventData.senderName || senderId); + } + + if (eventData.source === 'UnityMessagingController' && senderId && senderId !== myUserId) { + showIncomingToast(eventData.senderName || senderId, eventData.message || ''); + } + }); + }).catch(function () { + // Unread history is a convenience; realtime delivery remains available. + }); + } + + function sendHeartbeat(force) { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + const now = Date.now(); + if (!force && now - lastHeartbeatSentAt < ACTIVITY_HEARTBEAT_THROTTLE_MS) { + return; + } + + lastHeartbeatSentAt = now; + connection.invoke('HeartbeatAsync').catch(function () { + // Ignore heartbeat failures; presence will simply age out. + }); + } + + function refreshPeers() { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + connection.invoke('GetTenantUsersAsync').then(function (result) { + applyPeers(result); + }).catch(function () { + // Peer list is a convenience for the compose "To" selector. + }); + } + + function applyPeers(result) { + peers = (Array.isArray(result) ? result : []).filter(function (p) { + return p?.userId && p.userId !== myUserId; + }); + renderTargetOptions(); + } + + function refreshTenant() { + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + connection.invoke('GetCurrentTenantAsync').then(function (result) { + currentTenant = result || null; + renderTargetOptions(); + }).catch(function () { + currentTenant = null; + }); + } + + function renderTargetOptions() { + const currentValue = widget.target.value; + + if (activeMode === 'tenant') { + widget.targetControl.style.display = 'none'; + widget.target.innerHTML = currentTenant + ? `` + : ``; + widget.target.disabled = !currentTenant; + if (currentTenant) { + widget.target.value = currentTenant.id; + } + updateTargetStatusDot(); + renderConversation(); + return; + } + + widget.targetControl.style.display = ''; + + if (peers.length === 0) { + widget.target.innerHTML = ``; + widget.target.disabled = true; + updateTargetStatusDot(); + return; + } + + widget.target.disabled = false; + widget.target.innerHTML = `` + peers + .map(function (p) { + const suffix = p.isOnline ? '' : ` (${l('RealtimeWidget:StatusOffline')})`; + return ``; + }) + .join(''); + + if (currentValue && peers.some(function (p) { return p.userId === currentValue; })) { + widget.target.value = currentValue; + } + + updateTargetStatusDot(); + renderConversation(); + } + + function getPeerStatus(peer) { + if (!peer?.isOnline) { + return { className: 'offline', label: l('RealtimeWidget:StatusOffline') }; + } + + const lastActivity = peer.lastActivityUtc ? new Date(peer.lastActivityUtc) : null; + const ageMs = lastActivity && !Number.isNaN(lastActivity.getTime()) + ? Date.now() - lastActivity.getTime() + : Number.POSITIVE_INFINITY; + + if (ageMs <= STATUS_GREEN_MS) { + return { className: 'green', label: l('RealtimeOps:StatusActive') }; + } + + if (ageMs <= STATUS_ORANGE_MS) { + return { className: 'orange', label: l('RealtimeOps:StatusIdle') }; + } + + return { className: 'red', label: l('RealtimeOps:StatusAway') }; + } + + function updateTargetStatusDot() { + if (activeMode === 'tenant') { + widget.targetStatus.className = 'rt-status-dot rt-widget-target-status rt-status-hidden'; + return; + } + + const peer = peers.find(function (p) { return p.userId === widget.target.value; }); + const status = getPeerStatus(peer); + widget.targetStatus.className = `rt-status-dot rt-widget-target-status rt-status-${status.className}`; + widget.targetStatus.title = status.label; + } + + widget.composeSend.addEventListener('click', sendComposeMessage); + widget.target.addEventListener('change', function () { + updateTargetStatusDot(); + renderConversation(); + }); + widget.modeTabs.forEach(function (tab) { + tab.addEventListener('click', function () { + activeMode = tab.dataset.mode; + widget.modeTabs.forEach(item => item.classList.toggle('active', item === tab)); + renderTargetOptions(); + }); + }); + widget.composeInput.addEventListener('keydown', function (e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendComposeMessage(); + } + }); + + function sendComposeMessage() { + const targetId = widget.target.value; + const message = widget.composeInput.value.trim(); + + if (!targetId || !message) { + return; + } + + if (connection.state !== signalR.HubConnectionState.Connected) { + return; + } + + const sendTask = activeMode === 'tenant' + ? connection.invoke('SendTenantMessageAsync', message) + : connection.invoke('SendPeerMessageAsync', targetId, message); + + sendTask.then(function () { + if (activeMode !== 'tenant') { + addMessage(activeMode, targetId, l('RealtimeWidget:You'), null, message, new Date().toISOString()); + } + widget.composeInput.value = ''; + }).catch(function () { + // Recipient is invalid or outside the tenant; leave the draft in place. + }); + } + + function addMessage(mode, targetId, sender, senderId, message, timestamp) { + const key = `${mode}:${targetId}`; + histories[key] = histories[key] || []; + histories[key].push({ sender, senderId, message, timestamp, mode }); + histories[key] = histories[key].slice(-MAX_HISTORY_ITEMS); + + if (!panelOpen) { + unreadCount += 1; + updateBadge(); + widget.bubble.classList.remove('rt-widget-bounce'); + widget.bubble.offsetWidth; + widget.bubble.classList.add('rt-widget-bounce'); + } + + renderConversation(); + } + + function ensurePeerOption(userId, userName) { + if (!userId) { + return; + } + + const existing = peers.find(peer => peer.userId === userId); + + if (existing) { + existing.isOnline = true; + existing.lastActivityUtc = new Date().toISOString(); + } else { + peers.push({ userId, userName, isOnline: true, connectionCount: 1, lastActivityUtc: new Date().toISOString() }); + } + } + + function renderConversation() { + const targetId = widget.target.value; + const items = targetId ? histories[`${activeMode}:${targetId}`] || [] : []; + widget.list.innerHTML = ''; + + if (items.length === 0) { + const empty = document.createElement('div'); + empty.className = 'rt-widget-empty'; + empty.textContent = l('RealtimeWidget:Empty'); + widget.list.appendChild(empty); + return; + } + + items.forEach(function (entry) { + const item = document.createElement('div'); + item.className = 'rt-widget-message' + (entry.mode === 'tenant' ? ' rt-widget-message-alert' : ''); + + const meta = document.createElement('div'); + meta.className = 'rt-widget-message-meta'; + const senderEl = document.createElement('span'); + senderEl.className = 'rt-widget-message-sender'; + senderEl.textContent = entry.mode === 'tenant' + ? `${entry.sender} (${l('RealtimeWidget:TenantBroadcast')})` + : entry.sender; + const timeEl = document.createElement('span'); + timeEl.className = 'rt-widget-message-time'; + timeEl.textContent = formatTime(entry.timestamp); + meta.appendChild(senderEl); + meta.appendChild(timeEl); + + const textEl = document.createElement('div'); + textEl.className = 'rt-widget-message-text'; + textEl.textContent = entry.message; + item.appendChild(meta); + item.appendChild(textEl); + widget.list.appendChild(item); + }); + + widget.list.scrollTop = widget.list.scrollHeight; + } + + function formatTime(value) { + const date = value ? new Date(value) : new Date(); + return Number.isNaN(date.getTime()) ? '' : date.toLocaleTimeString(); + } + + function updateBadge() { + if (unreadCount > 0) { + widget.badge.textContent = unreadCount > 9 ? '9+' : String(unreadCount); + widget.badge.classList.remove('rt-widget-hidden'); + } else { + widget.badge.classList.add('rt-widget-hidden'); + } + } + + function showIncomingToast(sender, message) { + if (typeof Swal !== 'undefined') { + Swal.fire({ + toast: true, + position: 'top-end', + icon: 'info', + title: sender, + text: message, + showConfirmButton: false, + timer: 5000, + timerProgressBar: true + }); + return; + } + + if (window.abp?.notify?.info && typeof window.abp.notify.info === 'function') { + abp.notify.info(`${sender}: ${message}`); + } + } + + function togglePanel() { + panelOpen = !panelOpen; + widget.panel.classList.toggle('rt-widget-panel-open', panelOpen); + widget.bubble.classList.toggle('rt-widget-hidden', panelOpen); + + if (panelOpen) { + unreadCount = 0; + updateBadge(); + connection.invoke('MarkMessagesReadAsync').catch(function () { + // Ignore read-state failures; messages remain available in the activity log. + }); + refreshPeers(); + refreshTenant(); + } + } + + function escapeHtml(value) { + const div = document.createElement('div'); + div.textContent = value || ''; + return div.innerHTML; + } + + function escapeAttribute(value) { + return escapeHtml(value).replaceAll('"', '"'); + } + + function buildWidget() { + const container = document.createElement('div'); + container.className = 'rt-widget-container'; + + const panel = document.createElement('div'); + panel.className = 'rt-widget-panel'; + + const header = document.createElement('div'); + header.className = 'rt-widget-panel-header'; + + const titleEl = document.createElement('span'); + titleEl.textContent = l('RealtimeWidget:Title'); + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'rt-widget-close'; + closeBtn.setAttribute('aria-label', 'Close'); + closeBtn.innerHTML = '×'; + closeBtn.addEventListener('click', togglePanel); + + header.appendChild(titleEl); + header.appendChild(closeBtn); + + const list = document.createElement('div'); + list.className = 'rt-widget-list'; + + const empty = document.createElement('div'); + empty.className = 'rt-widget-empty'; + empty.textContent = l('RealtimeWidget:Empty'); + list.appendChild(empty); + + const compose = document.createElement('div'); + compose.className = 'rt-widget-compose'; + + const modeTabs = document.createElement('div'); + modeTabs.className = 'rt-widget-mode-tabs'; + + const individualTab = document.createElement('button'); + individualTab.type = 'button'; + individualTab.className = 'rt-widget-mode-tab active'; + individualTab.dataset.mode = 'individual'; + individualTab.textContent = l('RealtimeWidget:Individual'); + + const tenantTab = document.createElement('button'); + tenantTab.type = 'button'; + tenantTab.className = 'rt-widget-mode-tab'; + tenantTab.dataset.mode = 'tenant'; + tenantTab.textContent = l('RealtimeWidget:Tenant'); + + modeTabs.appendChild(individualTab); + modeTabs.appendChild(tenantTab); + + const targetRow = document.createElement('div'); + targetRow.className = 'rt-widget-target-row'; + + const targetControl = document.createElement('div'); + targetControl.className = 'rt-widget-target-control'; + + const targetStatus = document.createElement('span'); + targetStatus.className = 'rt-status-dot rt-widget-target-status rt-status-hidden'; + + const target = document.createElement('select'); + target.className = 'rt-widget-compose-target'; + target.innerHTML = ``; + + targetControl.appendChild(targetStatus); + targetControl.appendChild(target); + targetRow.appendChild(targetControl); + + const composeRow = document.createElement('div'); + composeRow.className = 'rt-widget-compose-row'; + + const composeInput = document.createElement('input'); + composeInput.type = 'text'; + composeInput.className = 'rt-widget-compose-input'; + composeInput.placeholder = l('RealtimeWidget:MessagePlaceholder'); + + const composeSend = document.createElement('button'); + composeSend.type = 'button'; + composeSend.className = 'rt-widget-compose-send'; + composeSend.textContent = l('RealtimeWidget:Send'); + + composeRow.appendChild(composeInput); + composeRow.appendChild(composeSend); + compose.appendChild(modeTabs); + compose.appendChild(targetRow); + compose.appendChild(composeRow); + + panel.appendChild(header); + panel.appendChild(list); + panel.appendChild(compose); + + const bubble = document.createElement('button'); + bubble.type = 'button'; + bubble.className = 'rt-widget-bubble'; + bubble.setAttribute('aria-label', l('RealtimeWidget:Title')); + bubble.innerHTML = ''; + bubble.addEventListener('click', togglePanel); + + const badge = document.createElement('span'); + badge.className = 'rt-widget-badge rt-widget-hidden'; + bubble.appendChild(badge); + + container.appendChild(panel); + container.appendChild(bubble); + document.body.appendChild(container); + + return { container, panel, list, bubble, badge, modeTabs: [individualTab, tenantTab], targetControl, target, targetStatus, composeInput, composeSend }; + } + } +})(); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml index cc5a9ead15..45283ea594 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml @@ -56,6 +56,7 @@ @if (CurrentUser.IsInRole("ITOperations")) { Unity Admin + Unity Messaging Exception Logs } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ExternalLinkDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ExternalLinkDto.cs new file mode 100644 index 0000000000..330d5121ed --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ExternalLinkDto.cs @@ -0,0 +1,12 @@ +namespace Unity.GrantManager.ApplicantProfile.ProfileData; + +/// +/// Represents a link to be used within the Applicant Portal, including the URL, title, and description. +/// +public class ExternalLinkDto +{ + public required string Uri { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public int Order { get; set; } = -1; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs index 38b7855fdd..9bce8b13f2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/SubmissionInfoItemDto.cs @@ -1,15 +1,19 @@ using System; +using System.Collections.Generic; -namespace Unity.GrantManager.ApplicantProfile.ProfileData +namespace Unity.GrantManager.ApplicantProfile.ProfileData; + +public class SubmissionInfoItemDto { - public class SubmissionInfoItemDto - { - public Guid Id { get; set; } - public string LinkId { get; set; } = string.Empty; - public DateTime ReceivedTime { get; set; } - public DateTime SubmissionTime { get; set; } - public string ReferenceNo { get; set; } = string.Empty; - public string Type { get; set; } = string.Empty; - public string Status { get; set; } = string.Empty; - } + public Guid Id { get; set; } + public string LinkId { get; set; } = string.Empty; + public DateTime ReceivedTime { get; set; } + public DateTime SubmissionTime { get; set; } + public string ReferenceNo { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public ExternalLinkDto? RenewalLink { get; set; } + public List RelatedLinks { get; set; } = []; + public bool EligibleForRenewal { get; set; } + public string ApplicantMessage { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs index 02d5f484e5..07f08b9859 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ApplicationFormDto.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Unity.GrantManager.GrantApplications; using Volo.Abp.Application.Dtos; @@ -32,5 +33,7 @@ public class ApplicationFormDto : EntityDto public string? Prefix { get; set; } public SuffixConfigType? SuffixType { get; set; } public int? DefaultPaymentGroup { get; set; } + public List ExternalLinks { get; set; } = []; + public string ApplicantMessage { get; set; } = string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinkConfigDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinkConfigDto.cs new file mode 100644 index 0000000000..0eb4abd51c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinkConfigDto.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Unity.GrantManager.ApplicantProfile; + +namespace Unity.GrantManager.ApplicationForms; + +public class ExternalLinkConfigDto : IValidatableObject +{ + [Required] + [MaxLength(2048)] + public string Uri { get; set; } = string.Empty; + + [MaxLength(255)] + public string Title { get; set; } = string.Empty; + + [MaxLength(512)] + public string Description { get; set; } = string.Empty; + + public bool Published { get; set; } + + public ExternalLinkType ExternalLinkType { get; set; } = ExternalLinkType.Related; + + public int Order { get; set; } = -1; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!ExternalLinkUriValidator.IsValidHttpUri(Uri)) + { + yield return new ValidationResult( + "Uri must be an absolute, well-formed http or https URL.", + [nameof(Uri)]); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinkUriValidator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinkUriValidator.cs new file mode 100644 index 0000000000..1db4baae5e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinkUriValidator.cs @@ -0,0 +1,20 @@ +using System; + +namespace Unity.GrantManager.ApplicationForms; + +/// +/// Shared http/https-only URI validation to block script-scheme injection (e.g. javascript:, data:). +/// +public static class ExternalLinkUriValidator +{ + public static bool IsValidHttpUri(string? uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + return false; + } + + return Uri.TryCreate(uri, UriKind.Absolute, out var parsed) + && (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinksConfigDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinksConfigDto.cs new file mode 100644 index 0000000000..0429e526fd --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/ExternalLinksConfigDto.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Unity.GrantManager.ApplicantProfile; + +namespace Unity.GrantManager.ApplicationForms; + +public class ExternalLinksConfigDto : IValidatableObject +{ + public const int MaxRelatedLinks = 8; + + public ExternalLinkConfigDto? RenewalLink { get; set; } + + public List RelatedLinks { get; set; } = []; + + [MaxLength(512)] + public string ApplicantMessage { get; set; } = string.Empty; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (RenewalLink is { Published: true } && !ExternalLinkUriValidator.IsValidHttpUri(RenewalLink.Uri)) + { + yield return new ValidationResult( + "Renewal link visibility cannot be enabled without a valid renewal link URL.", + [nameof(RenewalLink)]); + } + + if (RelatedLinks is null || RelatedLinks.Exists(link => link is null)) + { + yield return new ValidationResult( + "Related links must be provided as a list of non-null items.", + [nameof(RelatedLinks)]); + yield break; + } + + if (RelatedLinks.Count > MaxRelatedLinks) + { + yield return new ValidationResult( + $"A maximum of {MaxRelatedLinks} related links is allowed.", + [nameof(RelatedLinks)]); + } + + if (RenewalLink is not null && RenewalLink.ExternalLinkType != ExternalLinkType.Renewal) + { + yield return new ValidationResult( + "Renewal link must be of type Renewal.", + [nameof(RenewalLink)]); + } + + if (RelatedLinks.Exists(l => l.ExternalLinkType != ExternalLinkType.Related)) + { + yield return new ValidationResult( + "Related links must all be of type Related.", + [nameof(RelatedLinks)]); + } + + for (var i = 0; i < RelatedLinks.Count; i++) + { + var link = RelatedLinks[i]; + if (link.Published && !ExternalLinkUriValidator.IsValidHttpUri(link.Uri)) + { + yield return new ValidationResult( + $"Related link visibility cannot be enabled without a valid URL (item {i + 1}).", + [nameof(RelatedLinks)]); + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs index 5de7519628..d452abddec 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/IApplicationFormAppService.cs @@ -19,6 +19,7 @@ public interface IApplicationFormAppService : ICrudAppService< Task> GetPublishedVersionsAsync(Guid id); Task PatchOtherConfig(Guid id, OtherConfigDto config); Task PatchAiConfig(Guid id, AIConfigDto config); + Task PatchExternalLinksConfigAsync(Guid id, ExternalLinksConfigDto config); Task GetFormPaymentApprovalThresholdByApplicationIdAsync(Guid applicationId); Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId); Task GetFormDetailsByApplicationIdAsync(Guid applicationId); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiScoresheetSuggestionName.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiScoresheetSuggestionName.cs new file mode 100644 index 0000000000..637caa2dae --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiScoresheetSuggestionName.cs @@ -0,0 +1,9 @@ +using System; + +namespace Unity.GrantManager.ApplicationForms.Mapping; + +public static class AiScoresheetSuggestionName +{ + public static string Build(Guid formId, Guid formVersionId) => + $"ai-form-{formId}-version-{formVersionId}-scoresheet"; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetSuggestionName.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetSuggestionName.cs index c85450926a..956fd1cb7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetSuggestionName.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicationForms/Mapping/AiWorksheetSuggestionName.cs @@ -5,5 +5,5 @@ namespace Unity.GrantManager.ApplicationForms.Mapping; public static class AiWorksheetSuggestionName { public static string Build(Guid formId, Guid formVersionId) => - $"ai-form-{formId}-version-{formVersionId}-field-suggestions"; + $"ai-form-{formId}-version-{formVersionId}-worksheet"; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkEmailNotificationDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkEmailNotificationDto.cs new file mode 100644 index 0000000000..449f52463d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkEmailNotificationDto.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.GrantApplications +{ + public class BulkEmailNotificationDto + { + public BulkEmailNotificationDto() + { + ValidationMessages = []; + ReferenceNo = string.Empty; + ApplicantName = string.Empty; + FormName = string.Empty; + ApplicationStatus = string.Empty; + } + + public List ValidationMessages { get; set; } + public bool IsValid { get; set; } + + public Guid ApplicationId { get; set; } + public Guid? EmailId { get; set; } + public string? EmailSubject { get; set; } + public string ReferenceNo { get; set; } + public string ApplicantName { get; set; } + public string FormName { get; set; } + public string ApplicationStatus { get; set; } + public decimal ApprovedAmount { get; set; } + public DateTime? DecisionDate { get; set; } + public string? CreatedByName { get; set; } + public DateTime? LastModified { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkEmailNotificationResultDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkEmailNotificationResultDto.cs new file mode 100644 index 0000000000..5f1e68a8f5 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/BulkEmailNotificationResultDto.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Unity.GrantManager.GrantApplications +{ + public class BulkEmailNotificationResultDto + { + public List Successes { get; set; } = []; + + [JsonProperty("failures")] + public List> Failures { get; set; } = []; + } +} 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 1e634c4df5..4861a89cb1 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 @@ -90,4 +90,5 @@ public class GrantApplicationDto : AuditedEntityDto public Guid? DefaultSiteId { get; set; } public ApplicationAnalysisResponse? AIAnalysisData { get; set; } public string? AIScoresheetAnswers { get; set; } + public bool EligibleForRenewal { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IBulkEmailNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IBulkEmailNotificationAppService.cs new file mode 100644 index 0000000000..bb53c56ce2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IBulkEmailNotificationAppService.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IBulkEmailNotificationAppService + { + Task SendBulkEmailNotifications(List batchApplicationsToEmail); + Task> GetApplicationsForBulkEmail(Guid[] applicationGuids); + Task RevalidateApplicationForBulkEmail(Guid applicationId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/QueueAttachmentSummaryRequestDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/QueueAttachmentSummaryRequestDto.cs deleted file mode 100644 index dd89554bfe..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/QueueAttachmentSummaryRequestDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Unity.GrantManager.GrantApplications; - -public class QueueAttachmentSummaryRequestDto -{ - public Guid ApplicationId { get; set; } - public List? AttachmentIds { get; set; } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs index 7f7bf9fb0a..ad51f44caf 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantManagerFeaturesDefinitionProvider.cs @@ -2,6 +2,7 @@ using Unity.GrantManager.Localization; using Unity.Modules.Shared.Specializations; using Unity.Notifications.Localization; +using Unity.Notifications.Features; using Unity.Payments.Localization; using Volo.Abp.Features; using Volo.Abp.Localization; @@ -28,6 +29,12 @@ public override void Define(IFeatureDefinitionContext context) .Create("Allow Notifications"), valueType: new ToggleStringValueType()); + myGroup.AddFeature(NotificationsFeatureConsts.DirectMessaging, + defaultValue: defaultValue, + displayName: LocalizableString + .Create("Allow Direct Messaging"), + valueType: new ToggleStringValueType()); + myGroup.AddFeature("Unity.Flex", defaultValue: defaultValue, displayName: LocalizableString diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/SubmissionInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/SubmissionInfoDataProvider.cs index d56d98fa28..4c5fe02c7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/SubmissionInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/DataProviders/SubmissionInfoDataProvider.cs @@ -66,27 +66,60 @@ join status in statusesQuery on application.ApplicationStatusId equals status.Id submission.CreationTime, submission.Submission, application.ReferenceNo, + application.EligibleForRenewal, FormName = form.ApplicationFormName ?? string.Empty, + form.ExternalLinksConfig, Status = application.ExternalStatusVisibility ? status.NotifiedStatus ?? status.ExternalStatus - : status.ExternalStatus + : status.ExternalStatus, }).ToListAsync(); - dto.Submissions.AddRange(results.Select(s => new SubmissionInfoItemDto + // ExternalLinksConfig is a JSON-mapped complex property; filtering its nested links as + // part of the join requires an APPLY operation that the SQLite test provider does not + // support, so the form's external links are resolved in-memory below instead of within + // the query. + dto.Submissions.AddRange(results.Select(s => { - Id = s.Id, - LinkId = s.LinkId, - ReceivedTime = s.CreationTime, - SubmissionTime = ResolveSubmissionTime(s.Submission, s.CreationTime), - ReferenceNo = s.ReferenceNo, - Type = s.FormName, - Status = s.Status + var renewalLinkEntity = s.EligibleForRenewal ? s.ExternalLinksConfig.Links + .Where(x => x.Published && x.ExternalLinkType == ExternalLinkType.Renewal) + .FirstOrDefault() : null; + + var renewalLink = renewalLinkEntity is null ? null : ToExternalLinkDto(renewalLinkEntity); + + var relatedLinks = s.ExternalLinksConfig.Links + .Where(x => x.Published && x.ExternalLinkType == ExternalLinkType.Related) + .OrderBy(x => x.Order == -1 ? int.MaxValue : x.Order) // Links without an order default to -1 + .Select(ToExternalLinkDto) + .ToList(); + + return new SubmissionInfoItemDto + { + Id = s.Id, + LinkId = s.LinkId, + ReceivedTime = s.CreationTime, + SubmissionTime = ResolveSubmissionTime(s.Submission, s.CreationTime), + ReferenceNo = s.ReferenceNo, + Type = s.FormName, + Status = s.Status, + RenewalLink = renewalLink, + RelatedLinks = relatedLinks, + EligibleForRenewal = s.EligibleForRenewal, + ApplicantMessage = renewalLinkEntity is null ? string.Empty : s.ExternalLinksConfig.ApplicantMessage + }; })); } return dto; } + private static ExternalLinkDto ToExternalLinkDto(ExternalLink link) => new() + { + Uri = link.Uri, + Order = link.Order, + Title = link.Title, + Description = link.Description + }; + /// /// Derives the CHEFS form view URL from the INTAKE_API_BASE dynamic URL setting. /// e.g. https://chefs-dev.apps.silver.devops.gov.bc.ca/app/api/v1 diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs index 01642a6fcb..0081f20ae6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormAppService.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.Applications; using Unity.GrantManager.Forms; using Unity.GrantManager.GrantApplications; @@ -125,6 +126,11 @@ public override async Task GetAsync(Guid id) var dto = await base.GetAsync(id); dto.ApiKey = _stringEncryptionService.Decrypt(dto.ApiKey); dto.ApiToken = _stringEncryptionService.Decrypt(dto.ApiToken); + + var form = await Repository.GetAsync(id); + dto.ExternalLinks = form.ExternalLinksConfig.Links.Select(MapToExternalLinkConfigDto).ToList(); + dto.ApplicantMessage = form.ExternalLinksConfig.ApplicantMessage; + return dto; } @@ -187,6 +193,41 @@ public async Task PatchAiConfig(Guid id, AIConfigDto config) await Repository.UpdateAsync(form); } + [Authorize(GrantManagerPermissions.ApplicationForms.Default)] + public async Task PatchExternalLinksConfigAsync(Guid id, ExternalLinksConfigDto config) + { + ArgumentNullException.ThrowIfNull(config); + + var form = await Repository.GetAsync(id); + + var renewalLink = config.RenewalLink is null ? null : MapToExternalLink(config.RenewalLink); + var relatedLinks = (config.RelatedLinks ?? []).Select(MapToExternalLink).ToList(); + + form.SetExternalLinks(renewalLink, relatedLinks, config.ApplicantMessage); + + await Repository.UpdateAsync(form); + } + + private static ExternalLink MapToExternalLink(ExternalLinkConfigDto dto) => new() + { + Uri = dto.Uri, + Title = dto.Title, + Description = dto.Description, + Published = dto.Published, + ExternalLinkType = dto.ExternalLinkType, + Order = dto.Order + }; + + private static ExternalLinkConfigDto MapToExternalLinkConfigDto(ExternalLink link) => new() + { + Uri = link.Uri, + Title = link.Title, + Description = link.Description, + Published = link.Published, + ExternalLinkType = link.ExternalLinkType, + Order = link.Order + }; + [Authorize(PaymentsPermissions.Payments.EditFormPaymentConfiguration)] public async Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs index 1a8192eb69..c60183318f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicationForms/ApplicationFormVersionAppService.cs @@ -13,9 +13,9 @@ using Unity.AI.Localization; using Unity.AI.Generation; using Unity.AI.Operations; -using Unity.AI.Permissions; using Unity.AI.Requests; using Unity.AI.Runtime.Execution; +using Unity.AI.Settings; using Unity.Flex.Domain.Worksheets; using Unity.Flex.Domain.Scoresheets; using Unity.Flex.Scoresheets.Enums; @@ -51,6 +51,7 @@ public class ApplicationFormVersionAppService( IApplicationFormSubmissionRepository formSubmissionRepository, IReportingFieldsGeneratorService reportingFieldsGeneratorService, IFeatureChecker featureChecker, + AIFeatureGuard aiFeatureGuard, IStringLocalizer localizer, IAIGenerationAppService aiGenerationAppService, IWorksheetRepository worksheetRepository, @@ -70,6 +71,13 @@ public class ApplicationFormVersionAppService( { private readonly IAIGenerationAppService _aiGenerationAppService = aiGenerationAppService; + private async Task EnsureAiOperationAccessAsync(string operationType, bool requiresGeneratePermission) + { + var operation = AIGenerationOperations.Get(operationType); + await aiFeatureGuard.EnsureEnabledAsync(operation.FeatureName, operation.DisabledLocalizationKey); + await CheckPolicyAsync(requiresGeneratePermission ? operation.GeneratePermission : operation.ViewPermission); + } + public override async Task CreateAsync(CreateUpdateApplicationFormVersionDto input) => await base.CreateAsync(input); @@ -379,7 +387,7 @@ await _aiGenerationAppService.SubmitAsync( [HttpGet("api/app/application-form-version/mapping-review")] public virtual async Task GetMappingReviewAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.ViewFormMapping); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormMapping, requiresGeneratePermission: false); var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync( AIGenerationOperations.FormMapping, formVersionId); @@ -391,7 +399,7 @@ public virtual async Task AcceptMappingSugges Guid formVersionId, AcceptMappingSuggestionsDto input) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormMapping, requiresGeneratePermission: true); var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync( AIGenerationOperations.FormMapping, formVersionId) @@ -439,7 +447,7 @@ public virtual async Task AcceptMappingSugges [HttpPost("api/app/application-form-version/discard-mapping-suggestions")] public virtual async Task DiscardMappingSuggestionsAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormMapping, requiresGeneratePermission: true); var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync( AIGenerationOperations.FormMapping, formVersionId); @@ -458,7 +466,7 @@ public virtual async Task DiscardMappingSuggestionsAsync(Guid formVersionId) [HttpPost("api/app/application-form-version/mapping-review-phase")] public virtual async Task SetMappingReviewPhaseAsync(Guid formVersionId, FormMappingReviewPhase phase) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormMapping, requiresGeneratePermission: true); var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync( AIGenerationOperations.FormMapping, formVersionId); @@ -511,8 +519,8 @@ public virtual async Task SetMappingReviewPhaseAsync(Guid formVersionId, FormMap [HttpPost("api/app/application-form-version/reset-ai-flow")] public virtual async Task ResetAiFlowAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping); - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormMapping, requiresGeneratePermission: true); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormWorksheet, requiresGeneratePermission: true); var formVersion = await Repository.GetAsync(formVersionId); var mappingReviews = await generationReviewRepository.GetListByOperationAndFormVersionAsync(AIGenerationOperations.FormMapping, formVersionId); var worksheetReviews = await generationReviewRepository.GetListByOperationAndFormVersionAsync(AIGenerationOperations.FormWorksheet, formVersionId); @@ -520,8 +528,7 @@ public virtual async Task ResetAiFlowAsync(Guid formVersionId) .SelectMany(review => GetWorksheetReviewPayload(review).DraftWorksheetIds) .Distinct() .ToList(); - var suggestionWorksheet = await worksheetRepository.GetByNameAsync( - AiWorksheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true); + var suggestionWorksheet = await GetAiSuggestionWorksheetAsync(formVersion); if (suggestionWorksheet != null && !worksheetIds.Contains(suggestionWorksheet.Id)) { worksheetIds.Add(suggestionWorksheet.Id); @@ -545,7 +552,7 @@ public virtual async Task ResetAiFlowAsync(Guid formVersionId) [HttpPost("api/app/application-form-version/finalize-mapping-review")] public virtual async Task FinalizeMappingReviewAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormMapping); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormMapping, requiresGeneratePermission: true); var formVersion = await Repository.GetAsync(formVersionId); var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync( AIGenerationOperations.FormMapping, @@ -577,7 +584,7 @@ await _aiGenerationAppService.SubmitAsync( [HttpGet("api/app/application-form-version/pending-ai-worksheet")] public virtual async Task GetPendingAiWorksheetAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.ViewFormWorksheet); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormWorksheet, requiresGeneratePermission: false); var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId); return worksheet == null ? null : MapAiWorksheetReview(worksheet); @@ -586,7 +593,7 @@ await _aiGenerationAppService.SubmitAsync( [HttpPost("api/app/application-form-version/create-ai-worksheet-draft")] public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, CreateAiWorksheetDraftDto input) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormWorksheet, requiresGeneratePermission: true); var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId); if (worksheet == null || worksheet.Id != input.SessionId) { @@ -675,7 +682,7 @@ public virtual async Task CreateAiWorksheetDraftAsync(Guid formVersionId, Create [HttpPost("api/app/application-form-version/discard-ai-worksheet-suggestions")] public virtual async Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormWorksheet); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormWorksheet, requiresGeneratePermission: true); var worksheet = await GetPendingAiWorksheetEntityAsync(formVersionId); if (worksheet != null) { @@ -694,7 +701,7 @@ public virtual async Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId) [HttpGet("api/app/application-form-version/pending-ai-scoresheet")] public virtual async Task GetPendingAiScoresheetAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.ViewFormScoresheet); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormScoresheet, requiresGeneratePermission: false); var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync( AIGenerationOperations.FormScoresheet, @@ -706,14 +713,14 @@ public virtual async Task DiscardAiWorksheetSuggestionsAsync(Guid formVersionId) var formVersion = await formVersionRepository.GetAsync(formVersionId); var scoresheet = await scoresheetRepository.GetByNameAsync( - BuildAiScoresheetSuggestionName(formVersion.ApplicationFormId, formVersion.Id), true); + AiScoresheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true); return scoresheet?.Published == false ? MapAiScoresheetReview(scoresheet) : null; } [HttpPost("api/app/application-form-version/create-ai-scoresheet-draft")] public virtual async Task CreateAiScoresheetDraftAsync(Guid formVersionId, CreateAiScoresheetDraftDto input) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormScoresheet); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormScoresheet, requiresGeneratePermission: true); var suggestion = await GetPendingAiScoresheetEntityAsync(formVersionId); if (suggestion == null || suggestion.Id != input.SessionId) @@ -800,7 +807,7 @@ public virtual async Task CreateAiScoresheetDraftAsync(Guid formVersionId, Creat [HttpPost("api/app/application-form-version/discard-ai-scoresheet-suggestions")] public virtual async Task DiscardAiScoresheetSuggestionsAsync(Guid formVersionId) { - await CheckPolicyAsync(AIPermissions.Analysis.GenerateFormScoresheet); + await EnsureAiOperationAccessAsync(AIGenerationOperations.FormScoresheet, requiresGeneratePermission: true); var suggestion = await GetPendingAiScoresheetEntityAsync(formVersionId); if (suggestion == null) { @@ -830,13 +837,10 @@ public virtual async Task DiscardAiScoresheetSuggestionsAsync(Guid formVersionId var formVersion = await formVersionRepository.GetAsync(formVersionId); var scoresheet = await scoresheetRepository.GetByNameAsync( - BuildAiScoresheetSuggestionName(formVersion.ApplicationFormId, formVersion.Id), true); + AiScoresheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true); return scoresheet?.Published == false ? scoresheet : null; } - private static string BuildAiScoresheetSuggestionName(Guid formId, Guid formVersionId) => - $"ai-form-{formId}-version-{formVersionId}-scoresheet"; - private static AiScoresheetReviewDto MapAiScoresheetReview(Scoresheet scoresheet) => new() { SessionId = scoresheet.Id, @@ -904,8 +908,7 @@ private async Task DeleteAiScoresheetSuggestionAsync(Scoresheet scoresheet) } var formVersion = await formVersionRepository.GetAsync(formVersionId); - var worksheet = await worksheetRepository.GetByNameAsync( - AiWorksheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true); + var worksheet = await GetAiSuggestionWorksheetAsync(formVersion); if (worksheet?.Published == false) { @@ -915,6 +918,12 @@ private async Task DeleteAiScoresheetSuggestionAsync(Scoresheet scoresheet) return null; } + private async Task GetAiSuggestionWorksheetAsync(ApplicationFormVersion formVersion) + { + return await worksheetRepository.GetByNameAsync( + AiWorksheetSuggestionName.Build(formVersion.ApplicationFormId, formVersion.Id), true); + } + private static AiWorksheetReviewDto MapAiWorksheetReview(Worksheet worksheet) => new() { SessionId = worksheet.Id, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs index 7f64eb9a4a..e256759e5a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormScoresheet/FormScoresheetOperationExecutor.cs @@ -10,6 +10,7 @@ using Unity.AI.Localization; using Unity.AI.Requests; using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.ApplicationForms.Mapping; using Unity.GrantManager.Applications; using Unity.Flex.Domain.Scoresheets; using Unity.Flex.Domain.ScoresheetInstances; @@ -48,7 +49,7 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a ?? throw new InvalidOperationException(localizer[AILocalizationKeys.ScoresheetGenerationRequiresFormVersion]); var formVersion = await applicationFormVersionRepository.GetAsync(applicationFormVersionId); var applicationForm = await applicationFormRepository.GetAsync(formVersion.ApplicationFormId); - var scoresheetName = BuildScoresheetName(formVersion.Id, applicationForm.Id); + var scoresheetName = AiScoresheetSuggestionName.Build(applicationForm.Id, formVersion.Id); var existingScoresheet = await scoresheetRepository.GetByNameAsync(scoresheetName, true); var review = await generationReviewRepository.FindLatestByOperationAndFormVersionAsync( AIGenerationOperations.FormScoresheet, @@ -180,11 +181,6 @@ private CreateScoresheetDto ParseScoresheetDefinition(string json) return dto; } - private static string BuildScoresheetName(Guid formVersionId, Guid formId) - { - return $"ai-form-{formId}-version-{formVersionId}-scoresheet"; - } - private Scoresheet BuildScoresheet(CreateScoresheetDto dto, string json, string scoresheetName) { var scoresheet = new Scoresheet(Guid.NewGuid(), dto.Title, scoresheetName); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs index e5b3153c58..c96228a233 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/Automation/Operations/FormWorksheet/FormWorksheetOperationExecutor.cs @@ -58,8 +58,8 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a return false; } - var worksheetName = baseWorksheetName; - var existingWorksheet = await worksheetRepository.GetByNameAsync(worksheetName, true); + var existingWorksheet = await worksheetRepository.GetByNameAsync(baseWorksheetName, true); + var worksheetName = existingWorksheet?.Name ?? baseWorksheetName; EnsureCanonicalSuggestionWorksheetState(existingWorksheet); var noSuggestionsGenerated = existingWorksheet != null && existingWorksheet.Sections.SelectMany(section => section.Fields).Any() == false; @@ -100,6 +100,11 @@ protected override async Task ExecuteAsync(AIGenerationBackgroundJobArgs a PromptVersion = args.PromptVersion }); + if (!string.IsNullOrWhiteSpace(worksheetResponse.FailureReason)) + { + throw new InvalidOperationException(worksheetResponse.FailureReason); + } + var suggestions = ParseWorksheetDefinition(worksheetResponse.Worksheet); noSuggestionsGenerated = suggestions.Count == 0; if (!noSuggestionsGenerated) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkEmailNotificationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkEmailNotificationAppService.cs new file mode 100644 index 0000000000..5fcac05aa3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/BulkEmailNotificationAppService.cs @@ -0,0 +1,269 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Notifications.Email; +using Unity.Modules.Shared.Utils; +using Unity.Notifications.Emails; +using Unity.Notifications.Permissions; +using Volo.Abp; +using Volo.Abp.Users; + +namespace Unity.GrantManager.GrantApplications +{ + // The right-panel editor reuses EmailsWidget, whose edit fieldset/Save/attachment handling all require + // Notifications.Email.Send (a sibling permission, not a parent of SendBulk) — so both are required here + // too, not just SendBulk, or a user granted SendBulk alone could reach this API into a state the UI can't + // actually support. Two stacked [Authorize] attributes compose as AND per standard ASP.NET Core semantics. + [Authorize(NotificationsPermissions.Email.SendBulk)] + [Authorize(NotificationsPermissions.Email.Send)] + public class BulkEmailNotificationAppService( + IApplicationRepository applicationRepository, + IEmailLogsRepository emailLogsRepository, + IEmailLogAttachmentRepository emailLogAttachmentRepository, + IEmailAppService emailAppService, + IExternalUserLookupServiceProvider externalUserLookupServiceProvider, + IConfiguration configuration) : GrantManagerAppService, IBulkEmailNotificationAppService + { + /// + /// Get applications for bulk email with added draft validation information + /// + /// + /// + public async Task> GetApplicationsForBulkEmail(Guid[] applicationGuids) + { + var applications = await applicationRepository.GetListByIdsAsync(applicationGuids); + var draftEmails = await emailLogsRepository.GetByApplicationIdsAndStatusAsync([.. applicationGuids], EmailStatus.Draft); + var draftsByApplication = draftEmails.GroupBy(e => e.ApplicationId).ToDictionary(g => g.Key, g => g.ToList()); + var draftAuthorsById = await GetDraftAuthorsAsync(draftEmails); + + var applicationsForEmail = new List(); + foreach (var application in applications) + { + draftsByApplication.TryGetValue(application.Id, out var drafts); + applicationsForEmail.Add(MapBulkEmailNotification(application, drafts ?? [], draftAuthorsById)); + } + + return applicationsForEmail; + } + + /// + /// Re-validate a single application's draft state (e.g. after an in-panel Save) without re-fetching the whole batch + /// + /// + /// + public async Task RevalidateApplicationForBulkEmail(Guid applicationId) + { + var applications = await applicationRepository.GetListByIdsAsync([applicationId]); + var application = applications.Single(); + var drafts = await emailLogsRepository.GetByApplicationIdsAndStatusAsync([applicationId], EmailStatus.Draft); + var draftAuthorsById = await GetDraftAuthorsAsync(drafts); + + return MapBulkEmailNotification(application, drafts, draftAuthorsById); + } + + private async Task> GetDraftAuthorsAsync(List drafts) + { + var creatorIds = drafts + .Where(d => d.CreatorId.HasValue) + .Select(d => d.CreatorId!.Value) + .Distinct(); + + var draftAuthorsById = new Dictionary(); + foreach (var creatorId in creatorIds) + { + var userInfo = await externalUserLookupServiceProvider.FindByIdAsync(creatorId); + if (userInfo != null) + { + draftAuthorsById[creatorId] = userInfo; + } + } + + return draftAuthorsById; + } + + /// + /// Send bulk email notifications for the given batch of draft emails + /// + /// + /// + public async Task SendBulkEmailNotifications(List batchApplicationsToEmail) + { + var bulkEmailResult = new BulkEmailNotificationResultDto(); + + // Fail the whole batch up front if notifications are disabled, rather than reporting false successes + // for emails that SendAsync would silently drop (it always returns true after publishing the event). + if (!await FeatureChecker.IsEnabledAsync("Unity.Notifications")) + { + foreach (var applicationToEmail in batchApplicationsToEmail) + { + bulkEmailResult.Failures.Add(new KeyValuePair(applicationToEmail.ReferenceNo, "Email notifications are currently disabled.")); + } + return bulkEmailResult; + } + + // We send individually here so that a failure on one application does not block the rest of the batch + foreach (var applicationToEmail in batchApplicationsToEmail) + { + try + { + if (!applicationToEmail.EmailId.HasValue) + { + throw new UserFriendlyException("No draft email was found for this application."); + } + + // Re-fetch the draft fresh (defense-in-depth: it may have changed since the modal opened) + var draft = await emailLogsRepository.GetAsync(applicationToEmail.EmailId.Value); + if (draft.Status != EmailStatus.Draft) + { + throw new UserFriendlyException("This email is no longer a draft."); + } + + // The posted ApplicationId is client-controlled (hidden form field) — never trust it for + // authorization-relevant writes. Confirm it still matches the draft's real owning application + // and use the draft's own ApplicationId, not the posted one, when sending. + if (draft.ApplicationId != applicationToEmail.ApplicationId) + { + throw new UserFriendlyException("This draft no longer matches the selected application."); + } + + // Re-check the "exactly one draft" invariant fresh at send time: this endpoint can be reached + // directly (bypassing the modal's GetApplicationsForBulkEmail check), and another draft may + // have been created for this application after the modal was loaded. + var currentDrafts = await emailLogsRepository.GetByApplicationIdsAndStatusAsync([draft.ApplicationId], EmailStatus.Draft); + if (currentDrafts.Count != 1) + { + throw new UserFriendlyException("Multiple draft emails found for this application. Please retain only one draft before proceeding."); + } + + // A non-blank ToAddress can still parse to zero recipients (e.g. ";" or ",") — the same check + // the send pipeline itself uses. Catch that here instead of reporting a false success: SendAsync + // always returns true, but the handler silently drops emails with no parseable recipients. + if (draft.ToAddress.ParseEmailList() is not { Count: > 0 }) + { + throw new UserFriendlyException("Draft email is missing a To address. Please update the draft before proceeding."); + } + + // Neither the per-upload size gate nor the modal's own UI check can catch every path an + // attachment can arrive by (e.g. copying a template's attachments onto a draft applies no + // size check at all), and this endpoint can be reached directly regardless of what the UI + // showed. FileSize is recorded on upload/copy, not derived from S3, so this is a cheap + // in-database check, not a storage round-trip. + var attachments = await emailLogAttachmentRepository.GetByEmailLogIdAsync(draft.Id); + var totalAttachmentMb = attachments.Sum(a => a.FileSize) * 0.000001; + // Same TryParse-with-fallback as AttachmentController's own total-size check: a missing, + // empty, malformed, or non-positive config value falls back to 25 rather than throwing — + // double.Parse would otherwise fail every row in the batch on a bad config value alone. + if (!double.TryParse(configuration["S3:EmailAttachmentsTotalMaxFileSize"], out double maxAttachmentMb) || maxAttachmentMb <= 0) + { + maxAttachmentMb = 25; + } + if (totalAttachmentMb > maxAttachmentMb) + { + throw new UserFriendlyException($"The total size of all attachments ({totalAttachmentMb:F2} MB) exceeds the maximum allowed {maxAttachmentMb} MB. Please remove one or more attachments before proceeding."); + } + + await emailAppService.SendAsync(new CreateEmailDto + { + EmailId = draft.Id, + ApplicationId = draft.ApplicationId, + EmailTo = draft.ToAddress, + EmailFrom = draft.FromAddress, + EmailSubject = draft.Subject, + EmailBody = draft.Body, + EmailCC = draft.CC, + EmailBCC = draft.BCC, + EmailTemplateName = draft.TemplateName, + SendOnDateTime = draft.SendOnDateTime + }); + + bulkEmailResult.Successes.Add(applicationToEmail.ReferenceNo); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error sending bulk email notification for application with ID: {ApplicationId} and ReferenceNo: {ReferenceNo}", + applicationToEmail.ApplicationId, + applicationToEmail.ReferenceNo); + + bulkEmailResult.Failures.Add(new KeyValuePair(applicationToEmail.ReferenceNo, ex.Message)); + } + } + + return bulkEmailResult; + } + + /// + /// Map the application to a BulkEmailNotificationDto with validation messages based on its draft emails + /// + /// + /// + /// + private static BulkEmailNotificationDto MapBulkEmailNotification(Application application, List drafts, Dictionary draftAuthorsById) + { + var validationMessages = new List(); + Guid? emailId = null; + string? emailSubject = null; + string? createdByName = null; + DateTime? lastModified = null; + + if (drafts.Count == 0) + { + validationMessages.Add("NO_DRAFT_FOUND"); + } + else if (drafts.Count > 1) + { + validationMessages.Add("MULTIPLE_DRAFTS_FOUND"); + } + else + { + var draft = drafts[0]; + emailId = draft.Id; + emailSubject = draft.Subject; + lastModified = draft.LastModificationTime ?? draft.CreationTime; + + if (draft.CreatorId.HasValue && draftAuthorsById.TryGetValue(draft.CreatorId.Value, out var author)) + { + createdByName = $"{author.Name} {author.Surname}".Trim(); + } + + if (string.IsNullOrWhiteSpace(draft.Subject)) + { + validationMessages.Add("MISSING_SUBJECT"); + } + if (draft.ToAddress.ParseEmailList() is not { Count: > 0 }) + { + validationMessages.Add("MISSING_TO_ADDRESS"); + } + if (string.IsNullOrWhiteSpace(draft.FromAddress)) + { + validationMessages.Add("MISSING_FROM_ADDRESS"); + } + if (string.IsNullOrWhiteSpace(draft.Body)) + { + validationMessages.Add("MISSING_BODY"); + } + } + + return new BulkEmailNotificationDto() + { + ApplicationId = application.Id, + EmailId = emailId, + EmailSubject = emailSubject, + ReferenceNo = application.ReferenceNo, + ApplicantName = application.Applicant?.ApplicantName ?? string.Empty, + ApplicationStatus = application.ApplicationStatus.InternalStatus, + FormName = application.ApplicationForm?.ApplicationFormName ?? string.Empty, + ApprovedAmount = application.ApprovedAmount, + DecisionDate = application.FinalDecisionDate, + CreatedByName = createdByName, + LastModified = lastModified, + ValidationMessages = validationMessages, + IsValid = validationMessages.Count == 0 + }; + } + } +} 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 536b503fce..fc36d12956 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -12,8 +12,10 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using Unity.AI.Generation; using Unity.AI.Models; using Unity.AI.Responses; +using Unity.AI.Settings; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; using Unity.GrantManager.Applicants; @@ -46,7 +48,6 @@ namespace Unity.GrantManager.GrantApplications; public class GrantApplicationAppService( IApplicationManager applicationManager, IApplicationRepository applicationRepository, - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, IApplicationStatusRepository applicationStatusRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicantRepository applicantRepository, @@ -55,7 +56,8 @@ public class GrantApplicationAppService( IApplicantAddressRepository applicantAddressRepository, IApplicantSupplierAppService applicantSupplierService, IPaymentRequestAppService paymentRequestService, - IFeatureChecker featureChecker) + IFeatureChecker featureChecker, + AIFeatureGuard aiFeatureGuard) : GrantManagerAppService, IGrantApplicationAppService #pragma warning restore S107 // Methods should not have too many parameters { @@ -69,6 +71,25 @@ public class GrantApplicationAppService( WriteIndented = true }; + private async Task EnsureAiOperationAccessAsync(string operationType, bool requiresGeneratePermission) + { + var operation = AIGenerationOperations.Get(operationType); + await aiFeatureGuard.EnsureEnabledAsync(operation.FeatureName, operation.DisabledLocalizationKey); + await CheckPolicyAsync(requiresGeneratePermission ? operation.GeneratePermission : operation.ViewPermission); + } + + private async Task CanAccessAiOperationAsync(string operationType, bool requiresGeneratePermission) + { + var operation = AIGenerationOperations.Get(operationType); + if (!await featureChecker.IsEnabledAsync(operation.FeatureName)) + { + return false; + } + + var permission = requiresGeneratePermission ? operation.GeneratePermission : operation.ViewPermission; + return await AuthorizationService.IsGrantedAsync(permission); + } + public async Task> GetListAsync(GrantApplicationListInputDto input) { var listRecords = await applicationRepository.GetApplicationListRecordsAsync( @@ -352,7 +373,15 @@ public async Task GetAsync(Guid id) appDto.SectorSubSectorIndustryDesc = application.Applicant.SectorSubSectorIndustryDesc; } - appDto.AIAnalysisData = ParseAiAnalysisData(appDto.AIAnalysis); + if (await CanAccessAiOperationAsync(AIGenerationOperations.ApplicationAnalysis, requiresGeneratePermission: false)) + { + appDto.AIAnalysisData = ParseAiAnalysisData(appDto.AIAnalysis); + } + else + { + appDto.AIAnalysis = null; + appDto.AIAnalysisData = null; + } return appDto; } @@ -1200,65 +1229,6 @@ await LocalEventBus.PublishAsync( return applicationManager.GetWorkflowDiagram(isDirectApproval); } - private async Task EnsureAttachmentSummariesEnabledAsync() - { - if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) - { - throw new UserFriendlyException("AI attachment summaries are not enabled."); - } - } - - private async Task> ResolveAttachmentSummaryIdsAsync(QueueAttachmentSummaryRequestDto input) - { - if (input == null) - { - throw new UserFriendlyException("Attachment summary request is required."); - } - - if (input.ApplicationId == Guid.Empty) - { - throw new UserFriendlyException("Application id is required."); - } - - var applicationAttachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == input.ApplicationId)) - .Select(a => a.Id) - .ToList(); - - if (applicationAttachmentIds.Count == 0) - { - throw new UserFriendlyException("No attachments were found to generate summaries."); - } - - if (input.AttachmentIds is not { Count: > 0 }) - { - return applicationAttachmentIds; - } - - var applicationAttachmentIdSet = applicationAttachmentIds.ToHashSet(); - var selectedAttachmentIds = input.AttachmentIds.Distinct().ToList(); - if (selectedAttachmentIds.Any(id => !applicationAttachmentIdSet.Contains(id))) - { - throw new UserFriendlyException("One or more selected attachments do not belong to the application."); - } - - return selectedAttachmentIds; - } - - private async Task EnsureAIAnalysisEnabledAsync() - { - if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis")) - { - throw new UserFriendlyException("AI application analysis is not enabled."); - } - } - - private async Task EnsureScoringEnabledAsync() - { - if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) - { - throw new UserFriendlyException("AI scoring is not enabled."); - } - } #endregion APPLICATION WORKFLOW public async Task> GetAllApplicationsAsync() @@ -1329,11 +1299,13 @@ private static Dictionary ExtractCustomFieldsForWorksheet(dynami public async Task DismissAIAnalysisItemAsync(Guid applicationId, string itemId) { + await EnsureAiOperationAccessAsync(AIGenerationOperations.ApplicationAnalysis, requiresGeneratePermission: true); return await UpdateAIAnalysisItemDismissedStateAsync(applicationId, itemId, isDismissed: true); } public async Task RestoreAIAnalysisItemAsync(Guid applicationId, string itemId) { + await EnsureAiOperationAccessAsync(AIGenerationOperations.ApplicationAnalysis, requiresGeneratePermission: true); return await UpdateAIAnalysisItemDismissedStateAsync(applicationId, itemId, isDismissed: false); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs index 01bd3c112c..2da0edb47e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationMapperlyProfile.cs @@ -345,11 +345,15 @@ public partial class ApplicationFormToDtoMapper : MapperBase { public override partial ApplicationFormVersionDto Map(ApplicationFormVersion source); public override partial void Map(ApplicationFormVersion source, ApplicationFormVersionDto destination); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLink.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLink.cs new file mode 100644 index 0000000000..16dfe181b4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLink.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Represents a link to be used within the Applicant Portal, including the URI, title, and description. +/// +public class ExternalLink +{ + [MaxLength(2048)] + public required string Uri { get; set; } + + [MaxLength(255)] + public string Title { get; set; } = string.Empty; + + [MaxLength(512)] + public string Description { get; set; } = string.Empty; + + public ExternalLinkType ExternalLinkType { get; set; } = ExternalLinkType.Related; + public bool Published { get; set; } = false; + public int Order { get; set; } = -1; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLinkType.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLinkType.cs new file mode 100644 index 0000000000..4136ccd68f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLinkType.cs @@ -0,0 +1,7 @@ +namespace Unity.GrantManager.ApplicantProfile; + +public enum ExternalLinkType +{ + Related = 1, + Renewal = 2 +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLinksConfig.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLinksConfig.cs new file mode 100644 index 0000000000..7a5022a822 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/ApplicantProfile/ExternalLinksConfig.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Unity.GrantManager.ApplicantProfile; + +/// +/// Represents the Applicant Portal external links configuration for an application form, +/// including the message shown to applicants alongside the renewal link. +/// +public class ExternalLinksConfig +{ + [MaxLength(512)] + public string ApplicantMessage { get; set; } = string.Empty; + + public List Links { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs index c189918a53..bdab44ff44 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/GrantManagerDomainErrorCodes.cs @@ -26,4 +26,10 @@ public static class GrantManagerDomainErrorCodes public const string PayableFormRequiresHierarchy = "GrantManager:PayableFormRequiresHierarchy"; public const string ChildFormRequiresParentForm = "GrantManager:ChildFormRequiresParentForm"; public const string ChildFormCannotReferenceSelf = "GrantManager:ChildFormCannotReferenceSelf"; + + /* APPLICANT PORTAL EXTERNAL LINKS */ + public const string RenewalLinkRequiredForVisibility = "GrantManager:RenewalLinkRequiredForVisibility"; + public const string RenewalLinkInvalidUri = "GrantManager:RenewalLinkInvalidUri"; + public const string RelatedLinkInvalidUri = "GrantManager:RelatedLinkInvalidUri"; + public const string TooManyRelatedLinks = "GrantManager:TooManyRelatedLinks"; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 843cb70ef7..d4d82c3b51 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -91,6 +91,7 @@ "ApplicationList:StartAssessmentButton": "Start Assessment", "ApplicationList:CompleteAssessmentButton": "Complete Assessment", "ApplicationList:TagButton": "Tags", + "ApplicationList:SendEmailButton": "Send Drafted Email", "ApplicationList:ResyncSubmissionAttachmentsButton": "Resync", "ApplicationList:ManageTagButton": "Manage Tags", @@ -192,6 +193,24 @@ "ApplicationForms.Configuration.Notes:BypassAssessmentWorkflow": "Enabling this feature will bypass the Assessment workflow when you approve or deny submissions associated with this form.", "ApplicationForms.Configuration.Notes:SelectedApplicantElectoralAddress": "The selected address type will determine which submitted applicant address is used to extract the electoral district.", "ApplicationForms.Configuration.Warnings:ApplicantElectoralAddressTypeChange": "Changing the address type will only affect new submissions. Existing applications will retain the electoral district extracted from their original address type.", + "ApplicationForms.Configuration:ApplicantPortalLinks": "Applicant Portal Related Links & Visibility", + "ApplicationForms.Configuration:RenewalLink": "Renewal Link", + "ApplicationForms.Configuration:LinkUrl": "Link URL", + "ApplicationForms.Configuration:RenewalLinkDisplayName": "Renewal Link Display Name", + "ApplicationForms.Configuration:ShowRenewalLinksInPortal": "Show Renewal Links in Portal", + "ApplicationForms.Configuration:ApplicantMessage": "Applicant Message", + "ApplicationForms.Configuration:OtherLinks": "Other Links", + "ApplicationForms.Configuration:ShowOtherLinksInPortal": "Show in Portal", + "ApplicationForms.Configuration:LinkDisplayName": "Display Name", + "ApplicationForms.Configuration:LinkDescription": "Description", + "ApplicationForms.Configuration:AddLink": "Add Link", + "ApplicationForms.Configuration:RemoveLink": "Remove Link", + "ApplicationForms.Configuration.Notes:LinkVisibilityRequiresUrl": "If there is no link provided when Visibility is enabled, nothing will be displayed.", + "ApplicationForms.Configuration.Notes:MaxRelatedLinks": "A maximum of 8 other links can be added.", + "ApplicationForms.Configuration.Errors:InvalidUrl": "Please enter a valid, absolute http or https URL.", + "ApplicationForms.Configuration.Errors:RenewalLinkRequiredForVisibility": "A valid renewal link URL is required before enabling visibility.", + "ApplicationForms.Configuration.Errors:OtherLinkRequiredForVisibility": "A valid URL is required before enabling visibility for this link.", + "ApplicationForms.Configuration.Errors:MaxRelatedLinksReached": "A maximum of 8 other links is allowed.", "Intakes": "Intakes", @@ -271,6 +290,10 @@ "GrantManager:PayableFormRequiresHierarchy": "Please select a form hierarchy before saving a payable form.", "GrantManager:ChildFormRequiresParentForm": "Please select a parent form when the form hierarchy is set to Child.", "GrantManager:ChildFormCannotReferenceSelf": "A form cannot reference itself as the parent.", + "GrantManager:RenewalLinkRequiredForVisibility": "A valid renewal link URL is required before enabling renewal link visibility.", + "GrantManager:RenewalLinkInvalidUri": "The renewal link URL must be a valid, absolute http or https URL.", + "GrantManager:RelatedLinkInvalidUri": "Related link URLs must be valid, absolute http or https URLs.", + "GrantManager:TooManyRelatedLinks": "A maximum of 8 related links is allowed.", "GrantManager:CannotModifyAiAssessment": "AI assessments are read-only.", "GrantManager:CannotCloneNonAiAssessment": "Only AI assessments can be cloned.", "GrantManager:NotCommentOwner": "You can only delete your own comments.", @@ -535,6 +558,17 @@ "ApplicationBatchApprovalRequest:InvalidApprovedAmount": "Invalid Approved Amount, it must be greater than 0.00", "ApplicationBatchApprovalRequest:InvalidRecommendedAmount": "Invalid Recommended Amount, it must be greater than 0.00", + "SendEmailNotificationRequest:Title": "Send Email Notification", + "SendEmailNotificationRequest:SubmitButtonText": "Send", + "SendEmailNotificationRequest:CancelButtonText": "Cancel", + "SendEmailNotificationRequest:MaxCountExceeded": "You have exceeded the maximum number of items for bulk email. Please reduce the number to {0} or fewer", + "SendEmailNotificationRequest:NoDraftFound": "No draft email found for this application. Please create a draft email before proceeding.", + "SendEmailNotificationRequest:MultipleDraftsFound": "Multiple draft emails found for this application. Please retain only one draft before proceeding.", + "SendEmailNotificationRequest:MissingSubject": "Draft email is missing a Subject. Please update the draft before proceeding.", + "SendEmailNotificationRequest:MissingToAddress": "Draft email is missing a To address. Please update the draft before proceeding.", + "SendEmailNotificationRequest:MissingFromAddress": "Draft email is missing a From address. Please update the draft before proceeding.", + "SendEmailNotificationRequest:MissingBody": "Draft email is missing a Body. Please update the draft before proceeding.", + "ApplicationBatchPublishRequest:MaxCountExceeded": "You have exceeded the maximum number of items for bulk status publishing. Please reduce the number to {0} or fewer", "ApplicationBatchPublishRequest:MinCountExceeded": "You have no items selected for bulk status publishing. Please close this prompt to continue", "ApplicationBatchPublishRequest:ConfirmationNote": "By confirming, the selected application statuses will be published and made visible to applicants in the portal.", @@ -620,7 +654,10 @@ "WrongTenantError:Title": "An Error Occurred", "WrongTenantError:ApplicationTenant": "This application is in Tenant: {0}", + "WrongTenantError:ApplicantTenant": "This applicant is in Tenant: {0}", "WrongTenantError:CurrentTenant": "You are currently in Tenant: {0}", - "WrongTenantError:Instructions": "Please click on the Profile menu at the top right of the corner, click on Switch Grant Programs, and select the correct Tenant before proceeding to view the link." + "WrongTenantError:Instructions": "Please click on the Profile menu at the top right of the corner, click on Switch Grant Programs, and select the correct Tenant before proceeding to view the link.", + "WrongTenantError:ApplicantNotFoundPossibleTenant": "This applicant was not found in the current Tenant and may exist in another Tenant.", + "WrongTenantError:ApplicationNotFoundPossibleTenant": "This application was not found in the current Tenant and may exist in another Tenant." } } 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 ff5f68410b..16d7a932c5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Application.cs @@ -144,6 +144,8 @@ public virtual ApplicationStatus ApplicationStatus public string? AIAnalysis { get; set; } + public bool EligibleForRenewal { get; set; } + [Column(TypeName = "jsonb")] public string? AIScoresheetAnswers { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationForm.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationForm.cs index a2644e9363..b5d6139c57 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationForm.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/ApplicationForm.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.GrantApplications; +using Volo.Abp; using Volo.Abp.Domain.Entities.Auditing; using Volo.Abp.MultiTenancy; @@ -32,6 +34,8 @@ public class ApplicationForm : FullAuditedAggregateRoot, IMultiTenant public FormHierarchyType? FormHierarchy { get; set; } public Guid? ParentFormId { get; set; } public bool IsDirectApproval { get; set; } = false; + public ExternalLinksConfig ExternalLinksConfig { get; set; } = new(); + public bool AutomaticallyGenerateAIAnalysis { get; set; } = false; public bool ManuallyInitiateAIAnalysis { get; set; } = false; [MaxLength(100)] @@ -79,4 +83,55 @@ public static AddressType GetDefaultElectoralDistrictAddressType() { return AddressType.PhysicalAddress; } + + public const int MaxRelatedExternalLinks = 8; + + /// + /// Replaces the Renewal and Related external links as a set, enforcing that a link + /// cannot be marked visible in the Applicant Portal without a valid URI. + /// + public ApplicationForm SetExternalLinks(ExternalLink? renewalLink, List relatedLinks, string applicantMessage = "") + { + ArgumentNullException.ThrowIfNull(relatedLinks); + + // Cap the number of related links to the maximum allowed + if (relatedLinks.Count > MaxRelatedExternalLinks) + { + throw new BusinessException(GrantManagerDomainErrorCodes.TooManyRelatedLinks); + } + + // Validate that if a renewal link is published, it must have a valid URI + if (renewalLink is { Published: true } && string.IsNullOrWhiteSpace(renewalLink.Uri)) + { + throw new BusinessException(GrantManagerDomainErrorCodes.RenewalLinkRequiredForVisibility); + } + + // Validate that if any related link is published, it must have a valid URI + if (relatedLinks.Exists(l => l.Published && string.IsNullOrWhiteSpace(l.Uri))) + { + throw new BusinessException(GrantManagerDomainErrorCodes.RelatedLinkInvalidUri); + } + + var links = new List(); + + if (renewalLink is not null) + { + renewalLink.ExternalLinkType = ExternalLinkType.Renewal; + links.Add(renewalLink); + } + + for (var i = 0; i < relatedLinks.Count; i++) + { + relatedLinks[i].ExternalLinkType = ExternalLinkType.Related; + links.Add(relatedLinks[i]); + } + + ExternalLinksConfig = new ExternalLinksConfig + { + ApplicantMessage = applicantMessage, + Links = links + }; + + return this; + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs index 8a83f33587..a839abf533 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Permissions/PermissionGrantsDataSeeder.cs @@ -70,6 +70,7 @@ public PermissionGrantsDataSeeder(IPermissionDataSeeder permissionDataSeeder) public readonly List Notifications_CommonPermissions = [ NotificationsPermissions.Email.Default, NotificationsPermissions.Email.Send, + NotificationsPermissions.Email.SendBulk, NotificationsPermissions.Email.DeleteDraft ]; 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 6cd378639d..a25e36b48c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -1,24 +1,27 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System.Linq; +using System.Text.Json; +using Unity.Flex.EntityFrameworkCore; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; -using Unity.GrantManager.Intakes; using Unity.GrantManager.Assessments; using Unity.GrantManager.Comments; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GlobalTag; using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.Identity; +using Unity.GrantManager.Intakes; using Unity.GrantManager.Notifications; +using Unity.Notifications.EntityFrameworkCore; +using Unity.Payments.EntityFrameworkCore; +using Unity.Reporting.EntityFrameworkCore; using Volo.Abp.Data; using Volo.Abp.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore.Modeling; -using Unity.GrantManager.Identity; -using Unity.Payments.EntityFrameworkCore; -using Unity.Flex.EntityFrameworkCore; -using Unity.Notifications.EntityFrameworkCore; -using Unity.Reporting.EntityFrameworkCore; -using Unity.GrantManager.GlobalTag; -using Unity.GrantManager.Contacts; -using Unity.GrantManager.ApplicationForms; namespace Unity.GrantManager.EntityFrameworkCore { @@ -68,6 +71,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); + // ExternalLinksConfig is mapped as a JSON-serialized scalar via HasConversion below, + // not as an owned/entity type, so exclude it (and its nested ExternalLink type) from + // convention-based entity discovery. + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Entity(b => { b.ToTable(GrantManagerConsts.TenantTablePrefix + "Persons", @@ -123,6 +132,20 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.ConfigureByConvention(); //auto configure for the base class props b.Property(x => x.ApplicationFormName).IsRequired().HasMaxLength(255); + // Mapped as a JSON-serialized scalar (rather than EF's native JSON complex-type + // support) because that feature is relational-only and breaks under the + // EFCore.InMemory provider used by Unity.GrantManager.Web.Tests. + b.Property(x => x.ExternalLinksConfig) + .HasColumnName("ExternalLinks") + .HasColumnType("jsonb") + .IsRequired() + .HasConversion( + config => JsonSerializer.Serialize(config, (JsonSerializerOptions?)null), + json => JsonSerializer.Deserialize(json, (JsonSerializerOptions?)null)!, + new ValueComparer( + (left, right) => JsonSerializer.Serialize(left, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(right, (JsonSerializerOptions?)null), + config => JsonSerializer.Serialize(config, (JsonSerializerOptions?)null).GetHashCode(), + config => JsonSerializer.Deserialize(JsonSerializer.Serialize(config, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!)); b.HasOne().WithMany().HasForeignKey(x => x.IntakeId).IsRequired(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260813164219_AB33234_ApplicantPortalExternalLinks.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260813164219_AB33234_ApplicantPortalExternalLinks.Designer.cs new file mode 100644 index 0000000000..ca97882d1f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260813164219_AB33234_ApplicantPortalExternalLinks.Designer.cs @@ -0,0 +1,5309 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260813164219_AB33234_ApplicantPortalExternalLinks")] + partial class AB33234_ApplicantPortalExternalLinks + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FiscalYearEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("date"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("ReportsComments") + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.HasIndex("OrgName"); + + b.HasIndex("OrgNumber"); + + b.HasIndex("Status"); + + b.HasIndex("SupplierId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UnityApplicantId"); + + b.HasIndex("TenantId", "IsDeleted", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ApplicantAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DefaultSiteId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("EligibleForRenewal") + .HasColumnType("boolean"); + + b.Property("ExternalStatusVisibility") + .HasColumnType("boolean"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AutomaticallyGenerateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ManuallyInitiateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.ComplexCollection(typeof(List>), "ExternalLinks", "Unity.GrantManager.Applications.ApplicationForm.ExternalLinks#ExternalLink", b1 => + { + b1.IsRequired(); + + b1.Property("Description") + .IsRequired(); + + b1.Property("ExternalLinkType"); + + b1.Property("Order"); + + b1.Property("Published"); + + b1.Property("Title") + .IsRequired(); + + b1.Property("Uri") + .IsRequired(); + + b1 + .ToJson("ExternalLinks") + .HasColumnType("jsonb"); + }); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.HasIndex("TenantId", "IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("NotifiedStatus") + .HasColumnType("text"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditStatus") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("AuditorName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + 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("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("text"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OneTimeConsideration") + .HasColumnType("numeric"); + + b.Property("PaidDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .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("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .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("FiscalYear") + .HasColumnType("text"); + + b.Property("IncompleteReport") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Outstanding") + .HasColumnType("boolean"); + + b.Property("ReportDate") + .HasColumnType("timestamp without time zone"); + + b.Property("SignedOff") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ReportsHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicantComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.HasIndex("TenantId"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationStatus") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ApplicationStatusId") + .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("DateField") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EmailTemplateId") + .HasColumnType("uuid"); + + b.Property("EventType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Module") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerDetail") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ScheduledNotifications", "Notifications"); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DateField") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("ScheduledNotificationId"); + + b.HasIndex("ApplicationId", "ScheduledNotificationId", "DateField") + .IsUnique(); + + b.ToTable("ScheduledNotificationTracking", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EmailType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("Recipient") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OriginTemplateId") + .HasColumnType("uuid"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.HasIndex("TemplateId"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CancelledBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("CancelledBy"); + + b.Property("CancelledById") + .HasColumnType("uuid") + .HasColumnName("CancelledById"); + + b.Property("CancelledOn") + .HasColumnType("timestamp without time zone") + .HasColumnName("CancelledOn"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Notifications.ScheduledNotification", null) + .WithMany() + .HasForeignKey("ScheduledNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260813164219_AB33234_ApplicantPortalExternalLinks.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260813164219_AB33234_ApplicantPortalExternalLinks.cs new file mode 100644 index 0000000000..db8b26e47f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260813164219_AB33234_ApplicantPortalExternalLinks.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class AB33234_ApplicantPortalExternalLinks : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EligibleForRenewal", + table: "Applications", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ExternalLinks", + table: "ApplicationForms", + type: "jsonb", + nullable: false, + defaultValue: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EligibleForRenewal", + table: "Applications"); + + migrationBuilder.DropColumn( + name: "ExternalLinks", + table: "ApplicationForms"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260817212058_AddNotificationReadStateAndSender.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260817212058_AddNotificationReadStateAndSender.Designer.cs new file mode 100644 index 0000000000..fa14c4a63d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260817212058_AddNotificationReadStateAndSender.Designer.cs @@ -0,0 +1,5520 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260817212058_AddNotificationMessagingTables")] + partial class AddNotificationMessagingTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.ApplicationForms.GenerationReview", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContextId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Operation") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Sequence") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Operation", "ContextId", "Sequence") + .IsUnique(); + + b.ToTable("GenerationReviews", "AI"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FiscalYearEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("date"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("ReportsComments") + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.HasIndex("OrgName"); + + b.HasIndex("OrgNumber"); + + b.HasIndex("Status"); + + b.HasIndex("SupplierId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UnityApplicantId"); + + b.HasIndex("TenantId", "IsDeleted", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ApplicantAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DefaultSiteId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExternalStatusVisibility") + .HasColumnType("boolean"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AutomaticallyGenerateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ManuallyInitiateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.HasIndex("TenantId", "IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("NotifiedStatus") + .HasColumnType("text"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditStatus") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("AuditorName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + 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("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("text"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OneTimeConsideration") + .HasColumnType("numeric"); + + b.Property("PaidDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .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("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .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("FiscalYear") + .HasColumnType("text"); + + b.Property("IncompleteReport") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Outstanding") + .HasColumnType("boolean"); + + b.Property("ReportDate") + .HasColumnType("timestamp without time zone"); + + b.Property("SignedOff") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ReportsHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicantComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.HasIndex("TenantId"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationStatus") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ApplicationStatusId") + .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("DateField") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EmailTemplateId") + .HasColumnType("uuid"); + + b.Property("EventType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Module") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerDetail") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ScheduledNotifications", "Notifications"); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DateField") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("ScheduledNotificationId"); + + b.HasIndex("ApplicationId", "ScheduledNotificationId", "DateField") + .IsUnique(); + + b.ToTable("ScheduledNotificationTracking", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EmailType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("Recipient") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OriginTemplateId") + .HasColumnType("uuid"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.HasIndex("TemplateId"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Logs.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeliveryTarget") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Environment") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExceptionMessage") + .HasColumnType("text"); + + b.Property("ExceptionType") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeliveredRealtime") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("SenderDisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SenderUserId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StackExcerpt") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("NotificationType", "CreationTime"); + + b.HasIndex("TenantId", "CreationTime"); + + b.ToTable("NotificationLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.ReadStates.NotificationReadState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastReadAt") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId") + .IsUnique(); + + b.ToTable("NotificationReadStates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CancelledBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("CancelledBy"); + + b.Property("CancelledById") + .HasColumnType("uuid") + .HasColumnName("CancelledById"); + + b.Property("CancelledOn") + .HasColumnType("timestamp without time zone") + .HasColumnName("CancelledOn"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Notifications.ScheduledNotification", null) + .WithMany() + .HasForeignKey("ScheduledNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260817212058_AddNotificationReadStateAndSender.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260817212058_AddNotificationReadStateAndSender.cs new file mode 100644 index 0000000000..1cdce7c914 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260817212058_AddNotificationReadStateAndSender.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations; + +public partial class AddNotificationMessagingTables : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "Notifications"); + + migrationBuilder.Sql(@" + CREATE TABLE IF NOT EXISTS ""Notifications"".""NotificationLogs"" ( + ""Id"" uuid NOT NULL, + ""TenantId"" uuid, + ""UserId"" uuid, + ""SenderUserId"" uuid, + ""SenderDisplayName"" character varying(256), + ""NotificationType"" character varying(64) NOT NULL, + ""Channel"" character varying(32) NOT NULL, + ""Severity"" character varying(32) NOT NULL, + ""Title"" character varying(256) NOT NULL, + ""Message"" text NOT NULL, + ""Source"" character varying(200) NOT NULL, + ""SourceReference"" character varying(256), + ""PayloadJson"" jsonb, + ""CorrelationId"" character varying(128), + ""IsDeliveredRealtime"" boolean NOT NULL, + ""DeliveryTarget"" character varying(256), + ""ExceptionType"" character varying(256), + ""ExceptionMessage"" text, + ""StackExcerpt"" text, + ""CommitSha"" character varying(64), + ""Environment"" character varying(64), + ""ExtraProperties"" text NOT NULL, + ""ConcurrencyStamp"" character varying(40) NOT NULL, + ""CreationTime"" timestamp without time zone NOT NULL, + ""CreatorId"" uuid, + ""LastModificationTime"" timestamp without time zone, + ""LastModifierId"" uuid, + CONSTRAINT ""PK_NotificationLogs"" PRIMARY KEY (""Id"") + ); + + CREATE INDEX IF NOT EXISTS ""IX_NotificationLogs_CorrelationId"" + ON ""Notifications"".""NotificationLogs"" (""CorrelationId""); + CREATE INDEX IF NOT EXISTS ""IX_NotificationLogs_NotificationType_CreationTime"" + ON ""Notifications"".""NotificationLogs"" (""NotificationType"", ""CreationTime""); + CREATE INDEX IF NOT EXISTS ""IX_NotificationLogs_TenantId_CreationTime"" + ON ""Notifications"".""NotificationLogs"" (""TenantId"", ""CreationTime""); + + CREATE TABLE IF NOT EXISTS ""Notifications"".""NotificationReadStates"" ( + ""Id"" uuid NOT NULL, + ""TenantId"" uuid, + ""UserId"" uuid NOT NULL, + ""LastReadAt"" timestamp without time zone NOT NULL, + ""ExtraProperties"" text NOT NULL, + ""ConcurrencyStamp"" character varying(40) NOT NULL, + ""CreationTime"" timestamp without time zone NOT NULL, + ""CreatorId"" uuid, + ""LastModificationTime"" timestamp without time zone, + ""LastModifierId"" uuid, + CONSTRAINT ""PK_NotificationReadStates"" PRIMARY KEY (""Id"") + ); + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_NotificationReadStates_TenantId_UserId"" + ON ""Notifications"".""NotificationReadStates"" (""TenantId"", ""UserId""); + "); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NotificationReadStates", + schema: "Notifications"); + + migrationBuilder.DropTable( + name: "NotificationLogs", + schema: "Notifications"); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260818211028_AB33234_ExternalLinksApplicantMessage.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260818211028_AB33234_ExternalLinksApplicantMessage.Designer.cs new file mode 100644 index 0000000000..f321798dc0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260818211028_AB33234_ExternalLinksApplicantMessage.Designer.cs @@ -0,0 +1,5352 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260818211028_AB33234_ExternalLinksApplicantMessage")] + partial class AB33234_ExternalLinksApplicantMessage + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.ApplicationForms.GenerationReview", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContextId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Operation") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Sequence") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Operation", "ContextId", "Sequence") + .IsUnique(); + + b.ToTable("GenerationReviews", "AI"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FiscalYearEnd") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("date"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("ReportsComments") + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.HasIndex("OrgName"); + + b.HasIndex("OrgNumber"); + + b.HasIndex("Status"); + + b.HasIndex("SupplierId"); + + b.HasIndex("TenantId"); + + b.HasIndex("UnityApplicantId"); + + b.HasIndex("TenantId", "IsDeleted", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ApplicantAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DefaultSiteId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("EligibleForRenewal") + .HasColumnType("boolean"); + + b.Property("ExternalStatusVisibility") + .HasColumnType("boolean"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ReferenceNo"); + + b.HasIndex("TenantId", "SubmissionDate") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AutomaticallyGenerateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExternalLinksConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("ExternalLinks"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ManuallyInitiateAIAnalysis") + .HasColumnType("boolean"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.HasIndex("TenantId", "IsDeleted") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("NotifiedStatus") + .HasColumnType("text"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.HasIndex("TenantId", "ApplicationId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditStatus") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("AuditorName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + 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("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("text"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OneTimeConsideration") + .HasColumnType("numeric"); + + b.Property("PaidDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .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("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .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("FiscalYear") + .HasColumnType("text"); + + b.Property("IncompleteReport") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("Outstanding") + .HasColumnType("boolean"); + + b.Property("ReportDate") + .HasColumnType("timestamp without time zone"); + + b.Property("SignedOff") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("ReportsHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicantComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.HasIndex("TenantId"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationStatus") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ApplicationStatusId") + .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("DateField") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EmailTemplateId") + .HasColumnType("uuid"); + + b.Property("EventType") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Module") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerDetail") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ScheduledNotifications", "Notifications"); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("CreatorId") + .HasColumnType("uuid"); + + b.Property("DateField") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("ScheduledNotificationId"); + + b.HasIndex("ApplicationId", "ScheduledNotificationId", "DateField") + .IsUnique(); + + b.ToTable("ScheduledNotificationTracking", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EmailType") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("Recipient") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("ScheduledNotificationId") + .HasColumnType("uuid"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OriginTemplateId") + .HasColumnType("uuid"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.HasIndex("TemplateId"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecipientCategory") + .HasColumnType("text"); + + b.Property("RecipientIdentifier") + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CancelledBy") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("CancelledBy"); + + b.Property("CancelledById") + .HasColumnType("uuid") + .HasColumnName("CancelledById"); + + b.Property("CancelledOn") + .HasColumnType("timestamp without time zone") + .HasColumnName("CancelledOn"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("CreationTime"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.HasIndex("Status"); + + b.HasIndex("TenantId", "CreationTime") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ReportsHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicantComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Notifications.ScheduledNotificationTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Notifications.ScheduledNotification", null) + .WithMany() + .HasForeignKey("ScheduledNotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260818211028_AB33234_ExternalLinksApplicantMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260818211028_AB33234_ExternalLinksApplicantMessage.cs new file mode 100644 index 0000000000..0090a9c4b3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260818211028_AB33234_ExternalLinksApplicantMessage.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class AB33234_ExternalLinksApplicantMessage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Existing rows store ExternalLinks as a bare JSON array, with the applicant message + // (if any) saved in the renewal link's (ExternalLinkType 2) Description. Wrap them in + // the new { ApplicantMessage, Links } object shape, lifting that message out so it + // isn't silently lost. + migrationBuilder.Sql( + """ + UPDATE "ApplicationForms" AS form + SET "ExternalLinks" = jsonb_build_object( + 'ApplicantMessage', + COALESCE(( + SELECT link ->> 'Description' + FROM jsonb_array_elements(form."ExternalLinks") AS link + WHERE link ->> 'ExternalLinkType' = '2' + LIMIT 1 + ), ''), + 'Links', form."ExternalLinks") + WHERE jsonb_typeof(form."ExternalLinks") = 'array'; + """); + + migrationBuilder.AlterColumn( + name: "ExternalLinks", + table: "ApplicationForms", + type: "jsonb", + nullable: false, + defaultValue: "{\"ApplicantMessage\":\"\",\"Links\":[]}", + oldClrType: typeof(string), + oldType: "jsonb", + oldDefaultValue: "[]"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ExternalLinks", + table: "ApplicationForms", + type: "jsonb", + nullable: false, + defaultValue: "[]", + oldClrType: typeof(string), + oldType: "jsonb", + oldDefaultValue: "{\"ApplicantMessage\":\"\",\"Links\":[]}"); + + // Push the applicant message back into the renewal link's Description before + // dropping the wrapper, so a rollback doesn't lose a message edited after the + // forward migration ran. + migrationBuilder.Sql( + """ + UPDATE "ApplicationForms" AS form + SET "ExternalLinks" = COALESCE(( + SELECT jsonb_agg( + CASE + WHEN link ->> 'ExternalLinkType' = '2' + THEN jsonb_set(link, '{Description}', to_jsonb(form."ExternalLinks" ->> 'ApplicantMessage')) + ELSE link + END) + FROM jsonb_array_elements(form."ExternalLinks" -> 'Links') AS link + ), '[]'::jsonb) + WHERE jsonb_typeof(form."ExternalLinks") = 'object'; + """); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs index 27200c2138..5c44b8ca35 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -806,6 +806,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(40)") .HasColumnName("ConcurrencyStamp"); + b.Property("ContextId") + .HasColumnType("uuid"); + b.Property("CreationTime") .HasColumnType("timestamp without time zone") .HasColumnName("CreationTime"); @@ -819,9 +822,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("ExtraProperties"); - b.Property("ContextId") - .HasColumnType("uuid"); - b.Property("LastModificationTime") .HasColumnType("timestamp without time zone") .HasColumnName("LastModificationTime"); @@ -856,6 +856,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("GenerationReviews", "AI"); }); + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => { b.Property("Id") @@ -1356,6 +1357,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ElectoralDistrict") .HasColumnType("text"); + b.Property("EligibleForRenewal") + .HasColumnType("boolean"); + b.Property("ExternalStatusVisibility") .HasColumnType("boolean"); @@ -1816,6 +1820,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ElectoralDistrictAddressType") .HasColumnType("integer"); + b.Property("ExternalLinksConfig") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("ExternalLinks"); + b.Property("ExtraProperties") .IsRequired() .HasColumnType("text") @@ -3176,10 +3185,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsActive") .HasColumnType("boolean"); - b.Property("Module") - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - b.Property("IsDeleted") .ValueGeneratedOnAdd() .HasColumnType("boolean") @@ -3194,6 +3199,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("LastModifierId"); + b.Property("Module") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + b.Property("RecipientCategory") .HasColumnType("text"); @@ -3581,6 +3590,182 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("EmailLogAttachments", "Notifications"); }); + modelBuilder.Entity("Unity.Notifications.Logs.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CommitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeliveryTarget") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Environment") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExceptionMessage") + .HasColumnType("text"); + + b.Property("ExceptionType") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeliveredRealtime") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("NotificationType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PayloadJson") + .HasColumnType("jsonb"); + + b.Property("SenderDisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SenderUserId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceReference") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StackExcerpt") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("NotificationType", "CreationTime"); + + b.HasIndex("TenantId", "CreationTime"); + + b.ToTable("NotificationLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.ReadStates.NotificationReadState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastReadAt") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId") + .IsUnique(); + + b.ToTable("NotificationReadStates", "Notifications"); + }); + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => { b.Property("Id") diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 542ceb9b0a..967b5087cb 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -486,6 +486,7 @@ private void ConfigureBundles() .Configure(UnityThemeUX2Bundles.Styles.Global, bundle => { bundle.AddFiles("/global-styles.css"); + bundle.AddFiles("/css/notifications-realtime-widget.css"); }); options.StyleBundles.Configure( @@ -494,6 +495,13 @@ private void ConfigureBundles() { bundle.AddContributors(typeof(NotificationsStyleBundleContributor)); }); + + options.ScriptBundles.Configure( + UnityThemeUX2Bundles.Scripts.Global, + bundle => + { + bundle.AddContributors(typeof(NotificationsScriptBundleContributor)); + }); }); } @@ -676,7 +684,6 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseStaticFiles(); app.UseMiddleware(); app.UseMiddleware(); - app.UseMiddleware(); app.UseRouting(); app.UseHttpMetrics(); app.UseAuthentication(); @@ -689,6 +696,7 @@ public override void OnApplicationInitialization(ApplicationInitializationContex app.UseUnitOfWork(); app.UseDynamicClaims(); app.UseAuthorization(); + app.UseMiddleware(); if (IsProfilingAllowed(env, configuration)) { app.UseMiniProfiler(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs index 2a94b89bde..8f74478cba 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Identity/PolicyRegistrant.cs @@ -68,19 +68,24 @@ internal static void Register(ServiceConfigurationContext context) ITAdminOrITOperationsRoles, TenantManagementPermissions.Tenants.Create))); // ITAdmin-only: Tenant delete/connection-string management and Identity user - // creation/lookup - previously covered by the removed admin claim stamp. + // lookup - previously covered by the removed admin claim stamp. authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.Delete, policy => policy.AddRequirements(new RoleOrPermissionRequirement( [IdentityConsts.ITAdminRoleName], TenantManagementPermissions.Tenants.Delete))); authorizationBuilder.AddPolicy(TenantManagementPermissions.Tenants.ManageConnectionStrings, policy => policy.AddRequirements(new RoleOrPermissionRequirement( [IdentityConsts.ITAdminRoleName], TenantManagementPermissions.Tenants.ManageConnectionStrings))); - authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, - policy => policy.AddRequirements(new RoleOrPermissionRequirement( - [IdentityConsts.ITAdminRoleName], IdentityPermissions.Users.Create))); authorizationBuilder.AddPolicy(IdentityPermissions.UserLookup.Default, policy => policy.AddRequirements(new RoleOrPermissionRequirement( [IdentityConsts.ITAdminRoleName], IdentityPermissions.UserLookup.Default))); + + // ITAdmin or ITOperations: Identity user creation/import - UserImportAppService is + // also invoked internally (via TenantCreatedEventHandler/TenantManagerAssignmentEventHandler) + // to import the Program Manager(s) during ITOperations-driven tenant onboarding, so it can't + // be ITAdmin-only like the other host-scoped policies above without breaking that flow. + authorizationBuilder.AddPolicy(IdentityPermissions.Users.Create, + policy => policy.AddRequirements(new RoleOrPermissionRequirement( + ITAdminOrITOperationsRoles, IdentityPermissions.Users.Create))); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs index cd9c75e401..8ebf663cd6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ErrorCountingLoggerSink.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Unity.GrantManager.Logs; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Users; using Volo.Abp.Uow; namespace Unity.GrantManager.Web.Middleware; @@ -73,77 +75,99 @@ public void Emit(LogEvent logEvent) _persistenceInFlight = true; } - _ = Task.Run(async () => + Guid? tenantId = null; + Guid? userId = null; + string? userName = null; + + try + { + using var metadataScope = scopeFactory.CreateScope(); + var currentTenant = metadataScope.ServiceProvider.GetRequiredService(); + var currentUser = metadataScope.ServiceProvider.GetRequiredService(); + tenantId = currentTenant.Id; + userId = currentUser.Id; + userName = AbpUserTenantAccessor.GetCurrentUserName(metadataScope.ServiceProvider); + } + catch { - IsPersistingExceptionLog.Value = true; + // Persistence is best-effort; continue with host/unknown metadata. + } - try + // Do not inherit the request's ambient ABP unit of work. The request may be + // disposing its DbContext while this fire-and-forget persistence is running. + using (ExecutionContext.SuppressFlow()) + { + _ = Task.Run(async () => { - await using var scope = scopeFactory.CreateAsyncScope(); - var exceptionLogs = scope.ServiceProvider.GetService(); + IsPersistingExceptionLog.Value = true; - if (exceptionLogs == null) + try { - return; + await using var scope = scopeFactory.CreateAsyncScope(); + var exceptionLogs = scope.ServiceProvider.GetService(); + + if (exceptionLogs == null) + { + return; + } + + using (scope.ServiceProvider.GetRequiredService().Change(tenantId)) + { + var frame = logEvent.Exception == null + ? null + : ExceptionNotificationHelpers.GetTopFrame(logEvent.Exception); + string? sourceFile = frame?.File == null + ? null + : ExceptionNotificationHelpers.NormalizeRepoPath(frame.Value.File); + + // A fresh unit of work owns the context used by the background write. + using var uow = scope.ServiceProvider.GetRequiredService() + .Begin(requiresNew: true, isTransactional: false); + + await exceptionLogs.CreateAsync(new CreateExceptionLogDto + { + UserId = userId, + UserName = userName, + TenantName = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(scope.ServiceProvider), + NotificationType = logEvent.Exception == null + ? ExceptionLogType.PrometheusErrorCounterEvent + : ExceptionLogType.PrometheusExceptionCounterEvent, + Channel = ExceptionLogChannel.Prometheus, + Severity = logEvent.Level >= LogEventLevel.Fatal + ? ExceptionLogSeverity.Critical + : ExceptionLogSeverity.Error, + Title = "Prometheus Error Counter Event", + Message = logEvent.RenderMessage(), + Source = nameof(ErrorCountingLoggerSink), + IsDeliveredRealtime = false, + ExceptionType = logEvent.Exception?.GetType().FullName, + ExceptionMessage = logEvent.Exception?.Message, + StackExcerpt = logEvent.Exception?.StackTrace, + SourceFile = sourceFile, + SourceLine = frame?.Line, + Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + }); + + await uow.CompleteAsync(); + } } - - var frame = logEvent.Exception == null - ? null - : ExceptionNotificationHelpers.GetTopFrame(logEvent.Exception); - string? sourceFile = frame?.File == null - ? null - : ExceptionNotificationHelpers.NormalizeRepoPath(frame.Value.File); - - // Isolate this from whatever ambient unit of work/DbContext happens to be flowing - // through the captured ExecutionContext (e.g. the request that triggered this log - // event may still be mid-operation on its own DbContext) - without requiresNew, - // ABP's implicit [UnitOfWork] on CreateAsync would join that ambient one instead of - // getting its own, causing "a second operation was started on this context instance". - using var uow = scope.ServiceProvider.GetRequiredService() - .Begin(requiresNew: true, isTransactional: false); - - await exceptionLogs.CreateAsync(new CreateExceptionLogDto - { - UserId = AbpUserTenantAccessor.GetCurrentUserId(scope.ServiceProvider), - UserName = AbpUserTenantAccessor.GetCurrentUserName(scope.ServiceProvider), - TenantName = await AbpUserTenantAccessor.GetCurrentTenantNameAsync(scope.ServiceProvider), - NotificationType = logEvent.Exception == null - ? ExceptionLogType.PrometheusErrorCounterEvent - : ExceptionLogType.PrometheusExceptionCounterEvent, - Channel = ExceptionLogChannel.Prometheus, - Severity = logEvent.Level >= LogEventLevel.Fatal - ? ExceptionLogSeverity.Critical - : ExceptionLogSeverity.Error, - Title = "Prometheus Error Counter Event", - Message = logEvent.RenderMessage(), - Source = nameof(ErrorCountingLoggerSink), - IsDeliveredRealtime = false, - ExceptionType = logEvent.Exception?.GetType().FullName, - ExceptionMessage = logEvent.Exception?.Message, - StackExcerpt = logEvent.Exception?.StackTrace, - SourceFile = sourceFile, - SourceLine = frame?.Line, - Environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") - }); - - await uow.CompleteAsync(); - } - catch - { - lock (_persistenceGate) + catch { - _persistenceDisabledUntil = DateTimeOffset.UtcNow.Add(PersistenceBackoff); + lock (_persistenceGate) + { + _persistenceDisabledUntil = DateTimeOffset.UtcNow.Add(PersistenceBackoff); + } } - } - finally - { - IsPersistingExceptionLog.Value = false; - - lock (_persistenceGate) + finally { - _persistenceInFlight = false; + IsPersistingExceptionLog.Value = false; + + lock (_persistenceGate) + { + _persistenceInFlight = false; + } } - } - }); + }); + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs index 9cdff84b83..539c9f0a4b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionCounterMiddleware.cs @@ -42,7 +42,7 @@ public class ExceptionCounterMiddleware( "Total number of application exceptions", new CounterConfiguration { - LabelNames = new[] { "type" } + LabelNames = ["type"] }); internal static readonly string CommitSha = ParseCommitSha( @@ -194,7 +194,7 @@ private void QueueLogNotification(HttpContext context, Exception ex) if (blame != null) { facts.Add(new Fact { Name = "Author", Value = $"{blame.Author} <{blame.Email}>" }); - var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha.Substring(0, 7) : blame.CommitSha; + var shortSha = !string.IsNullOrEmpty(blame.CommitSha) && blame.CommitSha.Length > 7 ? blame.CommitSha[..7] : blame.CommitSha; facts.Add(new Fact { Name = "Commit", Value = $"{shortSha} {blame.Message}" }); if (blame.PullRequestUrl != null) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs index 1aa77fc4f8..3dd0353944 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Middleware/ExceptionNotificationHelpers.cs @@ -80,7 +80,7 @@ public static (string? File, int? Line)? GetTopFrame(Exception ex) { var trace = new StackTrace(ex, true); - foreach (var frame in trace.GetFrames() ?? Array.Empty()) + foreach (var frame in trace.GetFrames() ?? []) { var file = frame.GetFileName(); var line = frame.GetFileLineNumber(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs index c3e28bf1d6..9dd0b510fc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.cshtml.cs @@ -60,6 +60,7 @@ public async Task OnGetAsync() return RedirectToPage("/Error", new { httpStatusCode = 409, + entityType = "Applicant", applicationTenantName = applicationTenant?.Name ?? TenantId.Value.ToString(), currentTenantName = CurrentTenant.Name ?? "Host" }); @@ -75,7 +76,12 @@ public async Task OnGetAsync() } catch (Exception) { - return NotFound(); + return RedirectToPage("/Error", new + { + httpStatusCode = 404, + entityType = "Applicant", + currentTenantName = CurrentTenant.Name ?? "Host" + }); } } @@ -105,7 +111,12 @@ public async Task OnGetAsync() } catch (Exception) { - return NotFound(); + return RedirectToPage("/Error", new + { + httpStatusCode = 404, + entityType = "Applicant", + currentTenantName = CurrentTenant.Name ?? "Host" + }); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml index 2d9e883c5c..c825bd8936 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.cshtml @@ -21,12 +21,18 @@ PageLayout.Content.MenuItemName = "GrantManager.ApplicationForms"; PageLayout.Content.Title = "Application Mapping"; ViewBag.PageTitle = "Application Forms Mapping"; + var canViewFormMapping = await FeatureChecker.IsEnabledAsync("Unity.AI.FormMapping") && await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.View); + var canGenerateFormMapping = await FeatureChecker.IsEnabledAsync("Unity.AI.FormMapping") && await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.Generate); + var canViewFormWorksheet = await FeatureChecker.IsEnabledAsync("Unity.AI.FormWorksheet") && await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.View); + var canGenerateFormWorksheet = await FeatureChecker.IsEnabledAsync("Unity.AI.FormWorksheet") && await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate); + var canViewFormScoresheet = await FeatureChecker.IsEnabledAsync("Unity.AI.FormScoresheet") && await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.View); + var canGenerateFormScoresheet = await FeatureChecker.IsEnabledAsync("Unity.AI.FormScoresheet") && await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.Generate); var mappingReviewModal = new AiSuggestionReviewModalModel { ModalId = "aiMappingReviewModal", ModalLabelId = "aiMappingReviewModalLabel", - CanMutate = await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.Generate), - Title = "Review AI Mapping Suggestions", + CanMutate = canGenerateFormMapping, + Title = "Review Mapping Suggestions", SourceColumnTitle = "CHEFS Field", TargetColumnTitle = "Unity Core Field", FieldsId = "aiMappingReviewFields", @@ -43,8 +49,8 @@ { ModalId = "aiWorksheetReviewModal", ModalLabelId = "aiWorksheetReviewModalLabel", - CanMutate = await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate), - Title = "Create Worksheet Draft", + CanMutate = canGenerateFormWorksheet, + Title = "Review Worksheet Suggestions", SourceColumnTitle = "Source Field", TargetColumnTitle = "Worksheet Field", FieldsId = "aiWorksheetReviewFields", @@ -54,7 +60,7 @@ SelectAllId = "aiWorksheetReviewSelectAll", TitleInputId = "aiWorksheetTitle", TitleInputLabel = "Worksheet Title", - TitleInputPlaceholder = "e.g., Project details", + TitleInputPlaceholder = "e.g., Project worksheet", PrimaryActionId = "btn-create-ai-worksheet-draft", PrimaryActionText = "Create Draft", PrimaryActionDisabled = true, @@ -65,8 +71,8 @@ { ModalId = "aiScoresheetReviewModal", ModalLabelId = "aiScoresheetReviewModalLabel", - CanMutate = await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.Generate), - Title = "Review AI Scoresheet Suggestions", + CanMutate = canGenerateFormScoresheet, + Title = "Review Scoresheet Suggestions", SourceColumnTitle = "Generated Question", TargetColumnTitle = "Section", FieldsId = "aiScoresheetReviewFields", @@ -76,7 +82,7 @@ SelectAllId = "aiScoresheetReviewSelectAll", TitleInputId = "aiScoresheetTitle", TitleInputLabel = "Scoresheet Title", - TitleInputPlaceholder = "e.g., Application Assessment", + TitleInputPlaceholder = "e.g., Project scoresheet", PrimaryActionId = "btn-create-ai-scoresheet-draft", PrimaryActionText = "Add to Scoresheet", PrimaryActionDisabled = true, @@ -90,6 +96,8 @@ { + + @@ -108,6 +116,12 @@ + + + + + + @@ -167,7 +181,7 @@
Mapping Configuration
- @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.Generate)) + @if (canGenerateFormMapping) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.View)) + @if (canViewFormMapping) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate)) + @if (canGenerateFormWorksheet) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.View)) + @if (canViewFormWorksheet) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate)) + @if (canGenerateFormWorksheet) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.Generate)) + @if (canGenerateFormMapping) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.View)) + @if (canViewFormMapping) { } - @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormMapping.Generate) - && await PermissionChecker.IsGrantedAsync(AIPermissions.FormWorksheet.Generate)) + @if (canGenerateFormMapping && canGenerateFormWorksheet) {
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js index e7f9a8d0a2..6910444e86 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/Mapping.js @@ -1,5 +1,4 @@ $(function () { - const aiL = abp.localization.getResource('AI'); let availableChefFieldsString = document.getElementById('availableChefsFields').value; let existingMappingString = document.getElementById('existingMapping').value; let intakeFieldsString = document.getElementById('intakeProperties').value; @@ -61,6 +60,7 @@ $(function () { btnReviewMapping: $('#btn-review-mapping'), btnGenerateWorksheet: $('#btn-generate-worksheet'), btnGenerateScoresheet: $('#btn-generate-scoresheet'), + btnReviewScoresheet: $('#btn-review-scoresheet'), btnReviewWorksheet: $('#btn-review-worksheet'), scoresheetReviewModal: $('#aiScoresheetReviewModal'), scoresheetReviewFields: $('#aiScoresheetReviewFields'), @@ -145,10 +145,7 @@ $(function () { primaryText: 'Add to Scoresheet', continueReview: function () { const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); - abp.ajax({ - url: `/api/app/application-form-version/discard-ai-scoresheet-suggestions?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST' - }).done(function () { + globalThis.AIFormWorkflowApi.discardScoresheetSuggestions(formVersion).done(function () { setAiScoresheetPending(false); UIElements.scoresheetReviewModal.modal('hide'); loadMappingReview(false); @@ -171,6 +168,7 @@ $(function () { bindExistingMaps(); setupTooltips(); initializeUIConfiguration(); + restoreActiveGenerationMonitors(); loadMappingReview(false); loadAiScoresheetReview(false); } @@ -181,13 +179,86 @@ $(function () { }); } - function startWorksheetPhase(callback) { + function isCapabilityEnabled(flagId) { + return String($(`#${flagId}`).val() ?? '').toLowerCase() === 'true'; + } + + function isAiFormScoresheetViewEnabled() { + return isCapabilityEnabled('AIFormScoresheetViewEnabled'); + } + + function isAiFormScoresheetGenerateEnabled() { + return isCapabilityEnabled('AIFormScoresheetGenerateEnabled'); + } + + function isAiFormMappingViewEnabled() { + return isCapabilityEnabled('AIFormMappingViewEnabled'); + } + + function isAiFormMappingGenerateEnabled() { + return isCapabilityEnabled('AIFormMappingGenerateEnabled'); + } + + function isAiFormWorksheetViewEnabled() { + return isCapabilityEnabled('AIFormWorksheetViewEnabled'); + } + + function isAiFormWorksheetGenerateEnabled() { + return isCapabilityEnabled('AIFormWorksheetGenerateEnabled'); + } + + function resumeGenerationMonitorOnLoad({ isEnabled, $button, operationType, monitorGeneration }) { + if (!isEnabled || !$button?.length) { + return; + } + + const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); + if (!validateGuid(applicationId)) { + return; + } + + globalThis.AIGenerationApi.getStatus(applicationId, operationType).done(function (generationStatus) { + if (generationStatus?.generationRequest?.isActive !== true) { + return; + } + + const existingHtml = $button.html(); + globalThis.AIGenerationButtonState?.setGenerating($button); + monitorGeneration(applicationId, $button, existingHtml); + }); + } + + function restoreActiveGenerationMonitors() { + resumeGenerationMonitorOnLoad({ + isEnabled: isAiFormMappingGenerateEnabled(), + $button: UIElements.btnGenerate, + operationType: 'form-mapping', + monitorGeneration: monitorFormMappingGeneration + }); + + resumeGenerationMonitorOnLoad({ + isEnabled: isAiFormWorksheetGenerateEnabled(), + $button: UIElements.btnGenerateWorksheet, + operationType: 'form-worksheet', + monitorGeneration: monitorFormWorksheetGeneration + }); + + resumeGenerationMonitorOnLoad({ + isEnabled: isAiFormScoresheetGenerateEnabled(), + $button: UIElements.btnGenerateScoresheet, + operationType: 'form-scoresheet', + monitorGeneration: monitorFormScoresheetGeneration + }); + } + + function transitionToWorksheetReview(onSuccess, fallbackErrorMessage) { const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); - return abp.ajax({ - url: `/api/app/application-form-version/mapping-review-phase?formVersionId=${encodeURIComponent(formVersion)}&phase=WorksheetReview`, - type: 'POST' - }).done(callback).fail(function () { - abp.notify.error('', 'Unable to start worksheet generation.'); + return globalThis.AIFormWorkflowApi.startWorksheetReviewPhase(formVersion).done(function (result) { + if (typeof onSuccess === 'function') { + onSuccess(result); + } + }).fail(function (error) { + abp.notify.error('', error?.responseJSON?.error?.message || fallbackErrorMessage); }); } @@ -208,18 +279,11 @@ $(function () { UIElements.btnGenerateFinalMapping.on('click', finalizeMappingReview); UIElements.btnRestartAiFlow.on('click', restartAiFlow); UIElements.btnGenerateScoresheet.on('click', function () { - if (UIElements.btnGenerateScoresheet.attr('data-ai-pending') === 'true') { - loadAiScoresheetReview(true); - return; - } - - if (UIElements.btnGenerateScoresheet.attr('data-ai-can-generate') !== 'true') { - abp.notify.error('', aiL('AI:GenerateFormScoresheetPermissionRequired')); - return; - } - queueFormScoresheet(this); }); + UIElements.btnReviewScoresheet.on('click', function () { + loadAiScoresheetReview(true); + }); UIElements.btnCreateScoresheetDraft.on('click', function () { if (UIElements.btnCreateScoresheetDraft.attr('data-empty-confirmation') === 'true') { continueEmptyReview(reviewConfigs.scoresheet); @@ -312,6 +376,10 @@ $(function () { } function queueFormMapping(triggerButton = null) { + if (!isAiFormMappingGenerateEnabled() || !UIElements.btnGenerate.length) { + return; + } + if (UIElements.btnGenerate.attr('data-ai-pending') === 'true') { loadMappingReview(true); return; @@ -343,10 +411,7 @@ $(function () { globalThis.AIGenerationButtonState?.setGenerating($button); - abp.ajax({ - url: `/api/app/ai/generation/form-mapping?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST', - }) + globalThis.AIGenerationApi.queueFormMapping(applicationId, formVersion) .done(function (generationStatus) { const request = generationStatus?.generationRequest; const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; @@ -372,6 +437,10 @@ $(function () { } function queueFormWorksheet(triggerButton = null) { + if (!isAiFormWorksheetGenerateEnabled() || !UIElements.btnGenerateWorksheet.length) { + return; + } + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); if (!validateGuid(formVersion) || !validateGuid(applicationId)) { @@ -386,9 +455,9 @@ $(function () { return; } - startWorksheetPhase(function () { + transitionToWorksheetReview(function () { queueFormWorksheetCore(buttonElement); - }); + }, 'Unable to start worksheet generation.'); } function queueFormWorksheetCore(triggerButton = null) { @@ -400,10 +469,7 @@ $(function () { globalThis.AIGenerationButtonState?.setGenerating($button); - abp.ajax({ - url: `/api/app/ai/generation/form-worksheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST', - }) + globalThis.AIGenerationApi.queueFormWorksheet(applicationId, formVersion) .done(function (generationStatus) { const request = generationStatus?.generationRequest; const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; @@ -428,13 +494,14 @@ $(function () { } function monitorFormWorksheetGeneration(applicationId, $button, existingHtml) { + if (!isAiFormWorksheetGenerateEnabled() || !$button?.length) { + return; + } + globalThis.AIGenerationButtonState?.monitor({ $button, originalHtml: existingHtml, - getStatus: () => abp.ajax({ - url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-worksheet`, - type: 'GET' - }), + getStatus: () => globalThis.AIGenerationApi.getStatus(applicationId, 'form-worksheet'), onComplete: function () { refreshWorksheetAfterGeneration(); }, @@ -448,6 +515,10 @@ $(function () { } function queueFormScoresheet(triggerButton = null) { + if (!isAiFormScoresheetGenerateEnabled() || !UIElements.btnGenerateScoresheet.length) { + return; + } + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); if (!validateGuid(formVersion) || !validateGuid(applicationId)) { @@ -465,10 +536,7 @@ $(function () { globalThis.AIGenerationButtonState?.setGenerating($button); - abp.ajax({ - url: `/api/app/ai/generation/form-scoresheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST', - }) + globalThis.AIGenerationApi.queueFormScoresheet(applicationId, formVersion) .done(function (generationStatus) { const request = generationStatus?.generationRequest; const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; @@ -493,13 +561,14 @@ $(function () { } function monitorFormScoresheetGeneration(applicationId, $button, existingHtml) { + if (!isAiFormScoresheetGenerateEnabled() || !$button?.length) { + return; + } + globalThis.AIGenerationButtonState?.monitor({ $button, originalHtml: existingHtml, - getStatus: () => abp.ajax({ - url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-scoresheet`, - type: 'GET' - }), + getStatus: () => globalThis.AIGenerationApi.getStatus(applicationId, 'form-scoresheet'), onComplete: function () { refreshScoresheetAfterGeneration(); }, @@ -533,16 +602,17 @@ $(function () { } function loadAiWorksheetReview() { + if (!isAiFormWorksheetViewEnabled() || !UIElements.btnReviewWorksheet.length) { + return; + } + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); if (!validateGuid(formVersion)) { abp.notify.error('', 'Unable to review the worksheet because the Form Version ID is invalid.'); return; } - abp.ajax({ - url: `/api/app/application-form-version/pending-ai-worksheet?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'GET' - }) + globalThis.AIFormWorkflowApi.getPendingWorksheet(formVersion) .done(function (worksheet) { if (!worksheet) { setAiWorksheetPending(false); @@ -588,7 +658,7 @@ $(function () { .attr('id', fieldId) .attr('data-field-id', field.id) .attr('aria-label', `Include ${field.label || field.key || 'field'}`) - .prop('checked', field.selected !== false) + .prop('checked', false) .appendTo($switchContainer); $switchContainer.appendTo($switch); $switch.appendTo($row); @@ -637,12 +707,7 @@ $(function () { UIElements.btnCreateWorksheetDraft.prop('disabled', true); UIElements.btnDiscardWorksheet.prop('disabled', true); - abp.ajax({ - url: `/api/app/application-form-version/create-ai-worksheet-draft?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST', - contentType: 'application/json', - data: JSON.stringify({ sessionId, title, selectedFieldIds }) - }) + globalThis.AIFormWorkflowApi.createWorksheetDraft(formVersion, { sessionId, title, selectedFieldIds }) .done(function () { UIElements.worksheetTitle.val(''); abp.notify.success('', 'Draft worksheet created.'); @@ -658,10 +723,7 @@ $(function () { } function refreshAiWorksheetReviewAfterDraftCreation(formVersion) { - abp.ajax({ - url: `/api/app/application-form-version/pending-ai-worksheet?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'GET' - }) + globalThis.AIFormWorkflowApi.getPendingWorksheet(formVersion) .done(function (worksheet) { if (!worksheet) { UIElements.worksheetReviewModal.modal('hide'); @@ -693,10 +755,7 @@ $(function () { UIElements.btnCreateWorksheetDraft.prop('disabled', true); UIElements.btnDiscardWorksheet.prop('disabled', true); - abp.ajax({ - url: `/api/app/application-form-version/discard-ai-worksheet-suggestions?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST' - }) + globalThis.AIFormWorkflowApi.discardWorksheetSuggestions(formVersion) .done(function () { setAiWorksheetPending(false); UIElements.worksheetReviewModal.modal('hide'); @@ -721,12 +780,13 @@ $(function () { } function finalizeMappingReview() { + if (!isAiFormMappingGenerateEnabled()) { + return; + } + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); const applicationId = String(document.getElementById('applicationFormId')?.value ?? '').trim(); - abp.ajax({ - url: `/api/app/application-form-version/finalize-mapping-review?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST' - }).done(function () { + globalThis.AIFormWorkflowApi.finalizeMappingReview(formVersion).done(function () { monitorFormMappingGeneration(applicationId, UIElements.btnGenerate, UIElements.btnGenerate.html()); }).fail(function (error) { abp.notify.error('', error?.responseJSON?.error?.message || 'Publish and assign all AI worksheet drafts before generating mapping.'); @@ -735,11 +795,12 @@ $(function () { } function checkMappingReviewComplete() { + if (!isAiFormMappingViewEnabled()) { + return; + } + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); - abp.ajax({ - url: `/api/app/application-form-version/mapping-review?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'GET' - }).done(function (review) { + globalThis.AIFormWorkflowApi.getMappingReview(formVersion).done(function (review) { if (!review?.pendingSuggestions?.length) { UIElements.mappingReviewModal.modal('hide'); if (isFinalMappingPhase(review.phase)) { @@ -753,10 +814,7 @@ $(function () { function completeMappingReview() { const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); - abp.ajax({ - url: `/api/app/application-form-version/mapping-review-phase?formVersionId=${encodeURIComponent(formVersion)}&phase=Completed`, - type: 'POST' - }).done(function () { + globalThis.AIFormWorkflowApi.completeMappingReviewPhase(formVersion).done(function () { UIElements.btnGenerate.attr('data-ai-pending', 'false'); abp.notify.success('', 'AI mapping review completed.'); }); @@ -772,28 +830,26 @@ $(function () { } function setAiScoresheetPending(isPending) { - UIElements.btnGenerateScoresheet - .attr('data-ai-pending', isPending ? 'true' : 'false'); - UIElements.btnGenerateScoresheet.find('.ai-button-content span:last-child').text( - isPending ? 'Review Scoresheet' : 'Generate Scoresheet'); - - if (isPending) { - UIElements.btnGenerateScoresheet - .removeAttr('data-ai-cooldown-checking data-ai-rate-limit-disabled') - .prop('disabled', false); + UIElements.btnGenerateScoresheet.toggleClass('d-none', isPending); + UIElements.btnReviewScoresheet.toggleClass('d-none', !isPending); + + if (!isPending) { + globalThis.syncAIRateLimitButtons?.(); } } function loadAiScoresheetReview(showModal = true, showEmpty = true) { + if (!isAiFormScoresheetViewEnabled() || !UIElements.btnReviewScoresheet.length) { + setAiScoresheetPending(false); + return; + } + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); if (!validateGuid(formVersion)) { return; } - return abp.ajax({ - url: `/api/app/application-form-version/pending-ai-scoresheet?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'GET' - }).done(function (review) { + return globalThis.AIFormWorkflowApi.getPendingScoresheet(formVersion).done(function (review) { if (!review) { setAiScoresheetPending(false); if (showModal && showEmpty) { @@ -817,6 +873,7 @@ $(function () { } } }).fail(function () { + setAiScoresheetPending(false); if (showModal) { abp.notify.error('', 'Unable to load AI scoresheet suggestions.'); } @@ -827,7 +884,7 @@ $(function () { resetEmptyReviewModal(reviewConfigs.scoresheet); UIElements.scoresheetReviewFields.empty(); UIElements.scoresheetReviewSelectAll.prop('checked', false); - UIElements.scoresheetTitle.val(review.title || ''); + UIElements.scoresheetTitle.val(''); UIElements.scoresheetReviewFields.attr('data-session-id', review.sessionId || ''); const sections = review.sections || []; @@ -853,7 +910,7 @@ $(function () { class: 'form-check-input', 'data-question-id': question.id, 'data-question-section-id': section.id, - checked: question.selected !== false + checked: false }); $('
', { class: 'ai-suggestion-review__source', text: question.label || question.name || '' }).appendTo($row); if (!UIElements.scoresheetReviewModal.find('.ai-suggestion-review__panel').attr('data-hide-target-column')) { @@ -921,11 +978,10 @@ $(function () { } UIElements.btnCreateScoresheetDraft.prop('disabled', true); - abp.ajax({ - url: `/api/app/application-form-version/create-ai-scoresheet-draft?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST', - contentType: 'application/json', - data: JSON.stringify({ sessionId: sessionId, title: title, selectedQuestionIds: selectedQuestionIds }) + globalThis.AIFormWorkflowApi.createScoresheetDraft(formVersion, { + sessionId: sessionId, + title: title, + selectedQuestionIds: selectedQuestionIds }).done(function () { abp.notify.success('', 'Scoresheet draft created.'); loadAiScoresheetReview(true, false); @@ -944,10 +1000,7 @@ $(function () { .then(function (confirmed) { if (!confirmed) return; UIElements.btnDiscardScoresheet.prop('disabled', true); - abp.ajax({ - url: `/api/app/application-form-version/discard-ai-scoresheet-suggestions?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST' - }).done(function () { + globalThis.AIFormWorkflowApi.discardScoresheetSuggestions(formVersion).done(function () { setAiScoresheetPending(false); UIElements.scoresheetReviewModal.modal('hide'); abp.notify.success('', 'Remaining AI scoresheet suggestions discarded.'); @@ -960,13 +1013,14 @@ $(function () { } function monitorFormMappingGeneration(applicationId, $button, existingHtml) { + if (!isAiFormMappingGenerateEnabled() || !$button?.length) { + return; + } + globalThis.AIGenerationButtonState?.monitor({ $button, originalHtml: existingHtml, - getStatus: () => abp.ajax({ - url: `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=form-mapping`, - type: 'GET' - }), + getStatus: () => globalThis.AIGenerationApi.getStatus(applicationId, 'form-mapping'), onComplete: function () { refreshMappingAfterGeneration(applicationId); }, @@ -980,15 +1034,16 @@ $(function () { } function loadMappingReview(showModal = true) { + if (!isAiFormMappingViewEnabled() || (!UIElements.btnReviewMapping.length && !UIElements.btnReviewFinalMapping.length)) { + return; + } + const formVersion = String(document.getElementById('formVersionId')?.value ?? '').trim(); if (!validateGuid(formVersion)) { return; } - return abp.ajax({ - url: `/api/app/application-form-version/mapping-review?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'GET' - }) + return globalThis.AIFormWorkflowApi.getMappingReview(formVersion) .done(function (review) { setAiWorkflowReady(); updateWorkflowActions(review); @@ -1186,12 +1241,7 @@ $(function () { UIElements.btnAddMapping.prop('disabled', true); let result; try { - result = await abp.ajax({ - url: `/api/app/application-form-version/accept-mapping-suggestions?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST', - contentType: 'application/json', - data: JSON.stringify({ suggestionIds }) - }); + result = await globalThis.AIFormWorkflowApi.acceptMappingSuggestions(formVersion, suggestionIds); } catch (error) { abp.notify.error( '', @@ -1222,10 +1272,7 @@ $(function () { return; } - return abp.ajax({ - url: `/api/app/application-form-version/discard-mapping-suggestions?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST' - }) + return globalThis.AIFormWorkflowApi.discardMappingSuggestions(formVersion) .done(function () { UIElements.mappingReviewModal.modal('hide'); if (isFinalMappingPhase(UIElements.mappingReviewFields.attr('data-phase'))) { @@ -1255,10 +1302,7 @@ $(function () { } UIElements.btnRestartAiFlow.prop('disabled', true); - return abp.ajax({ - url: `/api/app/application-form-version/reset-ai-flow?formVersionId=${encodeURIComponent(formVersion)}`, - type: 'POST' - }).done(function () { + return globalThis.AIFormWorkflowApi.resetAiFlow(formVersion).done(function () { globalThis.location.reload(); }).fail(function (error) { abp.notify.error('', error?.responseJSON?.error?.message || 'Unable to restart the AI flow.'); @@ -1274,14 +1318,9 @@ $(function () { return; } - return abp.ajax({ - url: `/api/app/application-form-version/mapping-review-phase?formVersionId=${encodeURIComponent(formVersion)}&phase=WorksheetReview`, - type: 'POST' - }).done(function () { + return transitionToWorksheetReview(function () { loadMappingReview(false); - }).fail(function (error) { - abp.notify.error('', error?.responseJSON?.error?.message || 'Unable to continue to worksheet generation.'); - }); + }, 'Unable to continue to worksheet generation.'); } function refreshMappingAfterGeneration(applicationId, formVersion = null) { @@ -1326,10 +1365,10 @@ $(function () { function handleSaveEditMapping() { try { let jsonText = $('#jsonText').val(); - $.parseJSON(jsonText); + JSON.parse(jsonText); let mappingJsonStr = jsonText.replaceAll(/\s+/g, ' ').replaceAll(/(\r\n|\n|\r)/gm, ""); UIElements.btnSaveMapping.prop('disabled', true); - handleSaveMapping($.parseJSON(mappingJsonStr)); + handleSaveMapping(JSON.parse(mappingJsonStr)); handleCancelMapping(); abp.notify.success( @@ -1406,6 +1445,7 @@ $(function () { } } } catch (err) { + console.error('Unable to apply saved mapping.', err); abp.notify.error('', aiL('AI:SavedMappingApplyFailed')); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/ai-form-workflow-api.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/ai-form-workflow-api.js new file mode 100644 index 0000000000..1b772aaecc --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationForms/ai-form-workflow-api.js @@ -0,0 +1,84 @@ +(function (global) { + function request(url, type, data = null, contentType = null) { + const options = { url, type }; + if (data !== null) { + options.data = contentType === 'application/json' && typeof data !== 'string' + ? JSON.stringify(data) + : data; + } + if (contentType) { + options.contentType = contentType; + } + + return abp.ajax(options); + } + + function workflowUrl(path, formVersionId, query = '') { + return `/api/app/application-form-version/${path}?formVersionId=${encodeURIComponent(formVersionId)}${query}`; + } + + global.AIFormWorkflowApi = { + startWorksheetReviewPhase(formVersionId) { + return request(workflowUrl('mapping-review-phase', formVersionId, '&phase=WorksheetReview'), 'POST'); + }, + completeMappingReviewPhase(formVersionId) { + return request(workflowUrl('mapping-review-phase', formVersionId, '&phase=Completed'), 'POST'); + }, + getMappingReview(formVersionId) { + return request(workflowUrl('mapping-review', formVersionId), 'GET'); + }, + finalizeMappingReview(formVersionId) { + return request(workflowUrl('finalize-mapping-review', formVersionId), 'POST'); + }, + acceptMappingSuggestions(formVersionId, suggestionIds) { + return request( + workflowUrl('accept-mapping-suggestions', formVersionId), + 'POST', + { suggestionIds }, + 'application/json' + ); + }, + discardMappingSuggestions(formVersionId) { + return request(workflowUrl('discard-mapping-suggestions', formVersionId), 'POST'); + }, + resetAiFlow(formVersionId) { + return request(workflowUrl('reset-ai-flow', formVersionId), 'POST'); + }, + getPendingWorksheet(formVersionId) { + return request(workflowUrl('pending-ai-worksheet', formVersionId), 'GET'); + }, + createWorksheetDraft(formVersionId, input) { + return request( + workflowUrl('create-ai-worksheet-draft', formVersionId), + 'POST', + { + sessionId: input.sessionId, + title: input.title, + selectedFieldIds: input.selectedFieldIds + }, + 'application/json' + ); + }, + discardWorksheetSuggestions(formVersionId) { + return request(workflowUrl('discard-ai-worksheet-suggestions', formVersionId), 'POST'); + }, + getPendingScoresheet(formVersionId) { + return request(workflowUrl('pending-ai-scoresheet', formVersionId), 'GET'); + }, + createScoresheetDraft(formVersionId, input) { + return request( + workflowUrl('create-ai-scoresheet-draft', formVersionId), + 'POST', + { + sessionId: input.sessionId, + title: input.title, + selectedQuestionIds: input.selectedQuestionIds + }, + 'application/json' + ); + }, + discardScoresheetSuggestions(formVersionId) { + return request(workflowUrl('discard-ai-scoresheet-suggestions', formVersionId), 'POST'); + } + }; +})(globalThis); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.cshtml new file mode 100644 index 0000000000..bb12b65fbb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.cshtml @@ -0,0 +1,164 @@ +@page +@model Unity.GrantManager.Web.Pages.BulkEmailNotifications.SendEmailNotificationModalModel +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization + +@inject IStringLocalizer L + +@{ + Layout = null; +} + + + + + @if (ViewData["Error"] != null) + { + + } + else + { +
+
+ @* This form wraps only the left panel — deliberately not the whole modal. The right panel + injects EmailsWidget dynamically, which renders its own
; nesting + that inside this form would be invalid HTML with unreliable browser parsing behavior. + The bottom Send button lives in the modal footer, outside this form, and submits it via + the form="bulkEmailNotificationForm" attribute instead of DOM nesting. *@ + + + + + + + @for (var i = 0; i < Model.BulkEmailNotifications?.Count; i++) + { +
+ + +
+
@Model.BulkEmailNotifications[i].ReferenceNo
+
@Model.BulkEmailNotifications[i].ApplicantName
+
@string.Format("({0})", @Model.BulkEmailNotifications[i].FormName)
+
@Model.BulkEmailNotifications[i].ApplicationStatus
+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + @for (var j = 0; j < Model.BulkEmailNotifications[i].Notes?.Count; j++) + { + + + @if (Model.BulkEmailNotifications[i].Notes[j].IsError) + { + Error + } + else + { + Note + } + @Model.BulkEmailNotifications[i].Notes[j].Description + + + } + +
+ } +
+
+ +
+ Error @Model.MaxBatchCountExceededError +
+
+ + @* WAI-ARIA "Window Splitter" pattern (draggable/keyboard-resizable, not a decorative break) — role="separator" + tabindex="0" are intentional here, not an
. *@ + + +
+
+ Select an application to view its draft email. +
+ + + +
+
+ } +
+ + + + + + + +
+ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.cshtml.cs new file mode 100644 index 0000000000..00a2dbde7e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.cshtml.cs @@ -0,0 +1,200 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.Web.Pages.BulkEmailNotifications.ViewModels; +using Unity.Modules.Shared.Utils; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.GrantManager.Web.Pages.BulkEmailNotifications; + +public class SendEmailNotificationModalModel(IBulkEmailNotificationAppService bulkEmailNotificationAppService, + ApplicationIdsCacheService cacheService) : AbpPageModel +{ + [BindProperty] + public List? BulkEmailNotifications { get; set; } + + [TempData] + public int ApplicationsCount { get; set; } + + [TempData] + public bool Invalid { get; set; } + + [TempData] + public int MaxBatchCount { get; set; } + + [TempData] + public string? MaxBatchCountExceededError { get; set; } + + [TempData] + public bool MaxBatchCountExceeded { get; set; } + + public async Task OnGetAsync(string cacheKey) + { + MaxBatchCount = BatchApprovalConsts.MaxBatchCount; + BulkEmailNotifications = []; + MaxBatchCountExceededError = L["SendEmailNotificationRequest:MaxCountExceeded", BatchApprovalConsts.MaxBatchCount.ToString()].Value; + + try + { + // Retrieve application IDs from distributed cache + var selectedApplicationIds = await cacheService.GetApplicationIdsAsync(cacheKey); + + if (selectedApplicationIds == null || selectedApplicationIds.Count == 0) + { + Logger.LogWarning("Cache key expired or invalid: {CacheKey}", cacheKey.SanitizeField()); + ViewData["Error"] = "The session has expired. Please try selecting applications again."; + Invalid = true; + return; + } + + Guid[] applicationGuids = selectedApplicationIds.ToArray(); + + // Clean up cache after retrieval (one-time use) + await cacheService.RemoveAsync(cacheKey); + + if (!ValidCount(applicationGuids)) + { + MaxBatchCountExceeded = true; + } + + if (applicationGuids.Length == 0) + { + return; + } + + var applications = await bulkEmailNotificationAppService.GetApplicationsForBulkEmail(applicationGuids); + + foreach (var application in applications) + { + var bulkEmailNotification = new BulkEmailNotificationViewModel + { + ApplicationId = application.ApplicationId, + EmailId = application.EmailId, + ReferenceNo = application.ReferenceNo, + ApplicantName = application.ApplicantName, + EmailSubject = application.EmailSubject, + ApplicationStatus = application.ApplicationStatus, + FormName = application.FormName, + ApprovedAmount = application.ApprovedAmount, + DecisionDate = application.DecisionDate, + CreatedByName = application.CreatedByName, + LastModified = application.LastModified, + IsValid = application.IsValid + }; + + SetNotes(application, bulkEmailNotification); + BulkEmailNotifications.Add(bulkEmailNotification); + } + + Invalid = applications.Exists(s => !s.IsValid) || MaxBatchCountExceeded; + ApplicationsCount = applications.Count; + + Logger.LogInformation("Successfully loaded bulk email modal for {Count} applications", selectedApplicationIds.Count); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error loading bulk email modal"); + ViewData["Error"] = "An error occurred while loading the email form. Please try again."; + Invalid = true; + } + } + + private void SetNotes(BulkEmailNotificationDto application, BulkEmailNotificationViewModel bulkEmailNotification) + { + List notes = EmailNotificationNoteViewModel.CreateNotesList(localizer: L); + + foreach (var validation in application.ValidationMessages) + { + var index = notes.FindIndex(note => note.Key == validation); + if (index != -1) + { + notes[index] = new EmailNotificationNoteViewModel(validation, true, notes[index].Description, notes[index].IsError); + } + } + + bulkEmailNotification.Notes = notes; + } + + /// + /// Re-validate a single application's draft state (called after a Save in the right-hand edit panel) + /// so the row's validity/notes/Created By/Last Modified can be refreshed without reloading the whole batch. + /// + public async Task OnGetRevalidateAsync(Guid applicationId) + { + try + { + var application = await bulkEmailNotificationAppService.RevalidateApplicationForBulkEmail(applicationId); + + var viewModel = new BulkEmailNotificationViewModel { ApplicationId = application.ApplicationId }; + SetNotes(application, viewModel); + + return new OkObjectResult(new + { + applicationId = application.ApplicationId, + emailId = application.EmailId, + isValid = application.IsValid, + createdByName = application.CreatedByName, + lastModified = application.LastModified, + notes = viewModel.Notes + }); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error revalidating application {ApplicationId} for bulk email", applicationId); + // A 2xx status (including 204 No Content) is treated as success by the client's $.ajax().done() + // handler, which immediately dereferences fields on the response body — an empty success response + // would throw client-side. Return a genuine error status so it lands in .fail() instead. + return StatusCode(StatusCodes.Status500InternalServerError); + } + } + + public async Task OnPostAsync() + { + try + { + if (BulkEmailNotifications == null) return NoContent(); + + var emailRequests = MapBulkEmailRequests(); + + var result = await bulkEmailNotificationAppService.SendBulkEmailNotifications(emailRequests); + + return new OkObjectResult(result); + } + catch (Exception ex) + { + Logger.LogError(ex, "Error sending bulk email notifications"); + } + + return NoContent(); + } + + private List MapBulkEmailRequests() + { + var bulkEmailNotifications = new List(); + + foreach (var application in BulkEmailNotifications ?? []) + { + bulkEmailNotifications.Add(new BulkEmailNotificationDto() + { + ApplicationId = application.ApplicationId, + EmailId = application.EmailId, + ReferenceNo = application.ReferenceNo, + ApplicantName = application.ApplicantName ?? string.Empty, + ValidationMessages = [] + }); + } + + return bulkEmailNotifications; + } + + private static bool ValidCount(Guid[] applicationGuids) + { + return applicationGuids.Length <= BatchApprovalConsts.MaxBatchCount; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.css new file mode 100644 index 0000000000..b9cd2bc8fb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.css @@ -0,0 +1,126 @@ +/* Row/summary styling (.batch-approval-container, .approval-details-header, .approval-note-prefix, etc.) + is shared with the Approve Applications modal via ApproveApplicationsModal.css, loaded in the same bundle. */ + +/* Wider Submission # column for the send-email summary table: the shared .col-20/.col-75 split from + ApproveApplicationsModal.css is too narrow for this table's smaller (default-size) modal. */ +.custom-table .col-35 { + width: 35%; +} + +.custom-table .col-60 { + width: 60%; +} + +/* ---- Two-column layout: compressed application list on the left, draft editor on the right ---- */ + +#sendEmailNotificationModal .modal-dialog { + max-width: 95vw; +} + +.bulk-email-modal-body { + display: flex; + align-items: stretch; +} + +.bulk-email-list-panel { + flex: 0 0 40%; + min-width: 0; +} + +.bulk-email-list-panel .batch-approval-card { + max-height: 75vh; +} + +.bulk-email-edit-panel { + flex: 1 1 60%; + min-width: 0; + max-height: 75vh; + overflow-y: auto; + padding-left: 1rem; +} + +/* Draggable divider between the two panels. The border that used to sit on .bulk-email-edit-panel now lives + here instead, since this element is the visual (and interactive) separator between them. */ +.bulk-email-panel-divider { + flex: 0 0 auto; + width: 13px; + margin: 0 -6px; + display: flex; + align-items: center; + justify-content: center; + cursor: col-resize; + position: relative; + z-index: 1; +} + +.bulk-email-panel-divider::before { + content: ''; + width: var(--bs-border-width, 1px); + height: 100%; + background-color: var(--bs-border-color); + transition: background-color 0.15s ease, width 0.15s ease; +} + +.bulk-email-panel-divider:hover::before, +.bulk-email-panel-divider.dragging::before, +.bulk-email-panel-divider:focus-visible::before { + width: 3px; + background-color: var(--bc-colors-blue-text-links); +} + +.bulk-email-panel-divider:focus-visible { + outline: none; +} + +/* Applied to for the duration of a drag so text selection and the resize cursor stay consistent even + if the pointer briefly moves outside the divider itself while dragging. */ +body.bulk-email-resizing { + cursor: col-resize; + user-select: none; +} + +/* Compressed row spacing for the left list, so it reads as a compact selectable index rather than + the full-width row layout used by the Approve Applications modal. */ +.bulk-email-row { + cursor: pointer; +} + +.bulk-email-row.row-selected { + border-color: var(--bc-colors-blue-text-links); + border-width: 2px; + background-color: var(--bc-colors-blue-light, #f0f6ff); +} + +.bulk-email-details-row { + padding: 0.25rem 0.5rem !important; +} + +.bulk-email-details-row abp-input, +.bulk-email-details-row .form-group { + margin-bottom: 0.25rem; +} + +/* ---- Right-panel empty/error/loading placeholders ---- */ + +.bulk-email-edit-empty, +.bulk-email-edit-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 20rem; + text-align: center; + color: var(--bc-type-color-secondary); + padding: 2rem; +} + +/* ---- EmailsWidget reuse: hide actions that don't apply inside the bulk-send edit panel. + Sending happens only via the bottom "Send" button once the whole batch is valid; Save/Discard + stay available so the user can fix and persist a draft in place. ---- */ +.bulk-email-edit-panel #btn-send-top, +.bulk-email-edit-panel #btn-send-dropdown, +.bulk-email-edit-panel #btn-new-email, +.bulk-email-edit-panel #btn-send-close-top, +.bulk-email-edit-panel #email-alert-readonly { + display: none !important; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.js new file mode 100644 index 0000000000..cf18b91635 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationModal.js @@ -0,0 +1,561 @@ +let selectedApplicationId = null; +let panelRequestId = 0; +let panelRefreshXhr = null; +let bulkEmailSendConfirmed = false; + +const EMAILS_WIDGET_REFRESH_URL = '/GrantApplications/Widgets/Emails/RefreshEmails'; + +// Bumps the version counter every async step in the right-panel load flow is checked against, and aborts +// whatever widget-refresh request is currently in flight. Called from every place that changes what the panel +// should show without necessarily going through loadDraftIntoPanel itself (the no-draft/error branch, resetting +// the panel, modal close) — loadDraftIntoPanel bumps/aborts on its own, but those other paths don't touch it at +// all otherwise, which would leave a stale in-flight request free to land later with nothing to stop it. +function invalidatePendingPanelRequest() { + panelRequestId++; + if (panelRefreshXhr) { + panelRefreshXhr.abort(); + panelRefreshXhr = null; + } +} + +function removeApplicationEmail(containerId) { + const $row = $('#' + containerId); + const referenceNo = $row.find('input[name$=".ReferenceNo"]').val(); + + abp.message.confirm( + referenceNo + ? 'Remove application ' + referenceNo + ' from this batch?' + : 'Remove this application from this batch?', + 'Remove Application', + function (confirmed) { + if (!confirmed) { + return; + } + + if (selectedApplicationId && containerId === selectedApplicationId + '_container') { + resetEditPanel(); + } + + $row.remove(); + let applicationsCount = $('#ApplicationsCount').val(); + $('#ApplicationsCount').val(applicationsCount - 1); + runValidations(); + } + ); +} + +function runValidations() { + let isValid = true; + let itemCount = 0; + + $('#bulkEmailNotificationForm input[name="BulkEmailNotifications.Index"]').each(function () { + itemCount++; + let index = $(this).val(); + let isValidField = $('#bulkEmailNotificationForm input[name="BulkEmailNotifications[' + index + '].IsValid"]').val(); + + if (isValidField.toLowerCase() !== 'true') { + isValid = false; + } + }); + + if (itemCount === 0) { + isValid = false; + } + + if (!validBatchCount()) { + isValid = false; + setMaxCountError(true); + } else { + setMaxCountError(false); + } + + if (isValid) { + enableBulkEmailSubmit(); + } else { + disableBulkEmailSubmit(); + } +} + +function setMaxCountError(visible) { + const summary = $('#batch-approval-summary'); + if (visible) { + summary.css('display', 'block'); + } else { + summary.css('display', 'none'); + } +} + +function validBatchCount() { + // .val() returns strings; comparing them with <= directly does a lexicographic (character-by-character) + // comparison, not a numeric one — e.g. "6" <= "50" is false, because '6' sorts after '5'. Parse both sides + // to numbers first so counts like 6-9 (and 60-69, 70-79, ...) aren't misreported as exceeding the max. + let applicationsCount = Number.parseInt($('#ApplicationsCount').val(), 10); + let maxBatchCount = Number.parseInt($('#MaxBatchCount').val(), 10); + return applicationsCount <= maxBatchCount; +} + +function enableBulkEmailSubmit() { + $("#sendEmailNotificationModal") + .find('#btnSubmitBulkEmail').prop("disabled", false); +} + +function disableBulkEmailSubmit() { + $("#sendEmailNotificationModal") + .find('#btnSubmitBulkEmail').prop("disabled", true); +} + +function closeEmailNotifications() { + $('#sendEmailNotificationModal').modal('hide'); +} + +// ---- Right-panel draft edit/select behavior ---- + +function selectApplicationRow(applicationId, event) { + if (event) { + event.stopPropagation(); + } + + if (selectedApplicationId === applicationId) { + return; + } + + if (hasUnsavedEditorChanges()) { + abp.message.confirm( + 'You have unsaved changes in the current draft. Switching applications will discard them. Continue?', + 'Unsaved Changes', + function (confirmed) { + if (confirmed) { + proceedWithRowSelection(applicationId); + } + } + ); + return; + } + + proceedWithRowSelection(applicationId); +} + +function hasUnsavedEditorChanges() { + // showErrorState()/showEmptyState()/showLoadingState() only hide #bulkEmailEditPanelWidget via CSS — they + // don't clear its content. If the user switches away from a dirty draft to a row with no valid draft (or + // an empty selection), the previous row's #EmailForm is still sitting in the DOM, hidden but with its + // Save button still enabled. Without the visibility check, that stale, no-longer-shown draft would keep + // reporting as "dirty" on every subsequent row switch and on the bottom Send button, even though there's + // nothing left on screen for the user to have unsaved changes in. + const $panel = $('#bulkEmailEditPanelWidget'); + if (!$panel.is(':visible')) { + return false; + } + + const $saveBtn = $panel.find('#btn-save-top'); + return $saveBtn.length > 0 && !$saveBtn.prop('disabled'); +} + +function proceedWithRowSelection(applicationId) { + selectedApplicationId = applicationId; + + $('.bulk-email-row').removeClass('row-selected'); + const $row = $('#' + applicationId + '_container'); + $row.addClass('row-selected'); + + showCorrectStateForSelection(); +} + +// Re-derives and shows whatever the right panel should currently display for selectedApplicationId, from the +// left panel's own row data (never touched by widget refreshes). +function showCorrectStateForSelection() { + const $row = $('#' + selectedApplicationId + '_container'); + const emailId = $row.find('input[name$=".EmailId"]').val(); + + if (!emailId) { + // Not going through loadDraftIntoPanel here, so it won't invalidate whatever request a *previous* + // selection left in flight on its own — do it explicitly, or a stale response for that previous + // application could still land on top of this row's error state once it resolves. + invalidatePendingPanelRequest(); + showErrorState(getRowErrorHtml($row)); + return; + } + + loadDraftIntoPanel(selectedApplicationId, emailId); +} + +function getRowErrorHtml($row) { + const $errorNote = $row.find('.bulk-approval-notes-column').filter(function () { + return $(this).css('display') !== 'none'; + }).first(); + + return $errorNote.length ? $errorNote.html() : 'This application has no editable draft.'; +} + +function resetEditPanel() { + selectedApplicationId = null; + invalidatePendingPanelRequest(); + showEmptyState(); +} + +function showEmptyState() { + $('#bulkEmailEditPanelEmpty').show(); + $('#bulkEmailEditPanelError').hide(); + $('#bulkEmailEditPanelLoading').hide(); + $('#bulkEmailEditPanelWidget').hide(); +} + +function showErrorState(html) { + $('#bulkEmailEditPanelEmpty').hide(); + $('#bulkEmailEditPanelError').html(html).show(); + $('#bulkEmailEditPanelLoading').hide(); + $('#bulkEmailEditPanelWidget').hide(); +} + +function showLoadingState() { + $('#bulkEmailEditPanelEmpty').hide(); + $('#bulkEmailEditPanelError').hide(); + $('#bulkEmailEditPanelLoading').show(); + $('#bulkEmailEditPanelWidget').hide(); +} + +function showWidgetState() { + $('#bulkEmailEditPanelEmpty').hide(); + $('#bulkEmailEditPanelError').hide(); + $('#bulkEmailEditPanelLoading').hide(); + $('#bulkEmailEditPanelWidget').show(); +} + +function loadDraftIntoPanel(applicationId, emailId) { + const requestId = ++panelRequestId; + + // Deliberately not using abp.WidgetManager for this: it shares one wrapper across every row selection, and + // its internal completion handling inserts whatever HTML a completed request returns into + // #bulkEmailEditPanelWidget unconditionally — there's no documented way to abort a previous in-flight + // request or gate the actual DOM write by a version/token. If row A is still refreshing when row B is + // selected and the user starts editing B's draft, A's response arriving late would silently overwrite the + // editor with A's blank markup — destroying whatever the user was typing, not just showing the wrong + // content (correcting it afterward can restore the right *saved* state, but it can't recover keystrokes + // that only ever existed in a DOM node that's already been replaced). + // + // Making the request ourselves gives an actual handle to abort the previous one. An aborted request's + // .done() is guaranteed by jQuery to never fire, so a stale response can't reach the DOM at all — this + // removes the race by construction instead of detecting and correcting it after the fact. The requestId + // check below covers the second leg of this flow (loadSelectedDraftData's own separate AJAX call), which + // has no abortable handle of its own — a version counter checked on arrival works there regardless. + if (panelRefreshXhr) { + panelRefreshXhr.abort(); + panelRefreshXhr = null; + } + + showLoadingState(); + + // EmailsWidget/Default.js reads its owning application from this ambient hidden field (normally + // rendered once by GrantApplications/Details.cshtml for a single fixed application). ActionBar/Default.cshtml + // renders an empty placeholder for it on page load; update it here before every refresh so the widget + // picks up whichever application is currently selected in this modal. + $('#DetailsViewApplicationId').val(applicationId); + + const currentUserId = decodeURIComponent($('#CurrentUserId').val()); + + panelRefreshXhr = $.ajax({ + url: EMAILS_WIDGET_REFRESH_URL, + type: 'GET', + data: { applicationId: applicationId, currentUserId: currentUserId } + }).done(function (html) { + panelRefreshXhr = null; + if (requestId !== panelRequestId) { + // Superseded by a newer row selection since this request was made — its own load already owns + // the panel now (or will shortly); this response is stale and must not touch the DOM. + return; + } + + $('#bulkEmailEditPanelWidget').html(html); + + // jquery-validation-unobtrusive only converts data-val-* attributes into jQuery Validate rules once, + // automatically, against whatever is already in the DOM when $(document).ready() fires. This markup + // arrives later over AJAX, so without re-parsing it here, EmailsWidget's own $(emailForm).valid() call + // (used for Subject/From/Body) would silently pass no matter what those fields contain — only the + // hand-validated "To" field would still be enforced client-side. + if ($.validator?.unobtrusive) { + $.validator.unobtrusive.parse('#bulkEmailEditPanelWidget'); + } + + // EmailsWidget/Default.js normally sets itself up exactly once, at page load, against markup that's + // already server-rendered on the page. Its edit fields/buttons/attachment handlers only just got + // inserted into the DOM by the refresh above, so its setup needs to run again against them — + // window.EmailsWidget.reinitialize() (added to Default.js for this reuse) does that. + window.EmailsWidget.reinitialize(); + loadSelectedDraftData(applicationId, emailId, requestId); + }).fail(function (jqXHR) { + panelRefreshXhr = null; + + if (jqXHR.statusText === 'abort' || requestId !== panelRequestId) { + // Either superseded (aborted by us) or, if it somehow still fired, superseded by a newer selection + // — either way that newer request owns what the panel shows next, not this failure. + return; + } + + console.warn('Failed to load draft editor for application:', applicationId, jqXHR); + showErrorState('Failed to load the draft email. Please try again.'); + }); +} + +function loadSelectedDraftData(applicationId, emailId, requestId) { + unity.notifications.emailNotifications.emailNotification.getHistoryByApplicationId(applicationId) + .then(function (history) { + if (requestId !== panelRequestId) { + // A newer row selection has started since this call began — that selection's own load is + // responsible for the panel now, so don't publish this stale draft's data over whatever the + // user may already be doing there. + return; + } + + const draft = (history || []).find(function (item) { return item.id === emailId; }); + + if (!draft) { + showErrorState('The selected draft could not be loaded. It may have changed — try re-selecting this application.'); + return; + } + + PubSub.publish('email_selected', draft); + showWidgetState(); + }) + .catch(function (e) { + if (requestId !== panelRequestId) { + return; + } + + console.warn('Failed to load draft email for bulk edit panel:', e); + showErrorState('Failed to load the draft email. Please try again.'); + }); +} + +function formatDateOnly(isoString) { + return isoString ? isoString.substring(0, 10) : ''; +} + +function applyRevalidationResult(applicationId, result) { + const $row = $('#' + applicationId + '_container'); + $row.find('input[name$=".IsValid"]').val(result.isValid ? 'true' : 'false'); + $row.find('input[name$=".EmailId"]').val(result.emailId || ''); + $row.find('input[name$=".CreatedByName"]').val(result.createdByName || ''); + $row.find('input[name$=".LastModified"]').val(formatDateOnly(result.lastModified)); + + (result.notes || []).forEach(function (note) { + $('#' + applicationId + '_container_' + note.key).css('display', note.active ? 'block' : 'none'); + }); + + runValidations(); +} + +function revalidateRow(applicationId) { + $.ajax({ + url: '/BulkEmailNotifications/SendEmailNotificationModal?handler=Revalidate', + type: 'GET', + data: { applicationId: applicationId } + }).done(function (result) { + // Defensive: a 2xx response with no/empty body (e.g. a proxy or future server-side change) would + // otherwise crash applyRevalidationResult on result.isValid. The server-side fix returns a real error + // status on failure, so this is a second line of defense, not the primary one. + if (!result) { + console.warn('Revalidation returned an empty response for application:', applicationId); + return; + } + + applyRevalidationResult(applicationId, result); + + // Only touch the visible panel if the user is still on the row that was actually just saved. Save + // disables #btn-save-top immediately (before its AJAX call even resolves), which is also what + // hasUnsavedEditorChanges() checks — so the user is free to switch to a different row while a save is + // still in flight, with no "unsaved changes" prompt in the way. If they did, reloading the panel here + // would yank away whatever they're now doing on the newly-selected row; the row that was actually + // saved still gets its IsValid/notes/Created By/Last Modified refreshed above regardless. + if (applicationId !== selectedApplicationId) { + return; + } + + const $row = $('#' + applicationId + '_container'); + const emailId = $row.find('input[name$=".EmailId"]').val(); + + if (emailId) { + loadDraftIntoPanel(applicationId, emailId); + } else { + resetEditPanel(); + } + }).fail(function (e) { + console.warn('Failed to revalidate application after save:', applicationId, e); + }); +} + +// ---- Draggable divider between the list and edit panels ---- + +const PANEL_DIVIDER_MIN_LIST_WIDTH = 280; +const PANEL_DIVIDER_MIN_EDIT_WIDTH = 400; +const PANEL_DIVIDER_KEYBOARD_STEP = 24; + +function getPanelDividerElements() { + return { + $container: $('.bulk-email-modal-body'), + $list: $('#bulkEmailListPanel'), + $divider: $('#bulkEmailPanelDivider') + }; +} + +// Sets the list panel to an explicit pixel width (clamped so neither panel can be dragged/keyed below a +// usable minimum — the edit panel in particular needs enough room for TinyMCE's toolbar to lay out sanely) +// and keeps the divider's aria-value* attributes in sync for screen reader users, whether or not they ever +// actually drag it. +function applyListPanelWidth(widthPx) { + const { $container, $list, $divider } = getPanelDividerElements(); + if (!$container.length || !$list.length) { + return; + } + + const containerWidth = $container.width(); + const dividerWidth = $divider.outerWidth() || 0; + const maxListWidth = Math.max(containerWidth - dividerWidth - PANEL_DIVIDER_MIN_EDIT_WIDTH, PANEL_DIVIDER_MIN_LIST_WIDTH); + const clampedWidth = Math.min(Math.max(widthPx, PANEL_DIVIDER_MIN_LIST_WIDTH), maxListWidth); + + $list.css('flex', '0 0 ' + clampedWidth + 'px'); + + $divider.attr({ + 'aria-valuenow': Math.round(clampedWidth), + 'aria-valuemin': PANEL_DIVIDER_MIN_LIST_WIDTH, + 'aria-valuemax': Math.round(maxListWidth) + }); +} + +function startPanelDividerDrag(event) { + event.preventDefault(); + + const { $list, $divider } = getPanelDividerElements(); + const startX = event.pageX; + const startWidth = $list.outerWidth(); + + $divider.addClass('dragging'); + $('body').addClass('bulk-email-resizing'); + + function onDragMove(moveEvent) { + applyListPanelWidth(startWidth + (moveEvent.pageX - startX)); + } + + function onDragEnd() { + $(document).off('mousemove.bulkEmailDivider', onDragMove); + $(document).off('mouseup.bulkEmailDivider', onDragEnd); + $divider.removeClass('dragging'); + $('body').removeClass('bulk-email-resizing'); + } + + $(document).on('mousemove.bulkEmailDivider', onDragMove); + $(document).on('mouseup.bulkEmailDivider', onDragEnd); +} + +function handlePanelDividerKeydown(event) { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return; + } + + event.preventDefault(); + const { $list } = getPanelDividerElements(); + const step = event.key === 'ArrowLeft' ? -PANEL_DIVIDER_KEYBOARD_STEP : PANEL_DIVIDER_KEYBOARD_STEP; + applyListPanelWidth($list.outerWidth() + step); +} + +$(function () { + PubSub.subscribe('refresh_application_emails', function (_msg, data) { + // EmailsWidget publishes the applicationId that was actually saved/sent (see Default.js) — use that + // rather than assuming it's whatever row happens to be selected right now, since the user may have + // switched rows while the save was still in flight. Fall back to the current selection for any other + // publisher of this topic that doesn't pass one. + const savedApplicationId = data?.applicationId || selectedApplicationId; + if (!savedApplicationId) { + return; + } + revalidateRow(savedApplicationId); + }); + + // The bottom Send button only posts this modal's own form (ApplicationId/EmailId/IsValid hidden fields) — + // it has no idea what's currently typed into EmailsWidget's separate #EmailForm in the right panel, and the + // backend always sends whatever is last *saved* to the draft. Without this guard, unsaved edits would be + // silently dropped: the user would see "success" for content they never actually saved. + $(document).on('click', '#btnSubmitBulkEmail', function (event) { + if (bulkEmailSendConfirmed) { + bulkEmailSendConfirmed = false; + return; + } + + // EmailsWidget's own attachment-total-size check (checkTotalAttachmentSize in Default.js) only ever + // toggles a button inside its own markup, which this modal hides — it has no way to know about, and + // never disables, our own #btnSubmitBulkEmail. Reachable via applying a different email template + // while attachments already exist (copyTemplateAttachments bypasses the normal per-upload size gate). + // Unlike unsaved edits, there's no reasonable "send anyway" here — the total genuinely exceeds what's + // allowed — so this is a hard block, not a confirm-to-proceed prompt. is(':visible') is false whenever + // the panel itself is hidden (error/empty/loading state), so this can't fire for a stale, no-longer- + // shown row the same way hasUnsavedEditorChanges() could before its own visibility fix. + if ($('#email-attachment-size-error').is(':visible')) { + event.preventDefault(); + event.stopImmediatePropagation(); + abp.message.error( + 'The currently open draft has attachments exceeding the total size limit. Remove attachments before sending.', + 'Attachments Too Large' + ); + return; + } + + // Past this point the click is actually going to send the batch, so it always needs an explicit + // confirmation first — this button has no "are you sure" of its own otherwise, and an accidental click + // would send every drafted email in the list right away with nothing to stop it. + event.preventDefault(); + event.stopImmediatePropagation(); + + if (hasUnsavedEditorChanges()) { + abp.message.confirm( + 'The currently open draft has unsaved changes. They will not be included in this send unless you save first. Send anyway?', + 'Unsaved Changes', + function (confirmed) { + if (confirmed) { + bulkEmailSendConfirmed = true; + $('#btnSubmitBulkEmail').trigger('click'); + } + } + ); + return; + } + + abp.message.confirm( + 'Are you sure you want to send the drafted emails for all applications in this batch?', + 'Send Drafted Email', + function (confirmed) { + if (confirmed) { + bulkEmailSendConfirmed = true; + $('#btnSubmitBulkEmail').trigger('click'); + } + } + ); + }); + + // Delegated (rather than bound directly to #bulkEmailPanelDivider) because this script is bundled once at + // page load, while the divider itself is part of the modal's DOM, which ABP's ModalManager destroys and + // freshly re-renders on every open. + $(document).on('mousedown', '#bulkEmailPanelDivider', startPanelDividerDrag); + $(document).on('keydown', '#bulkEmailPanelDivider', handlePanelDividerKeydown); + + // Initializes the divider's aria-value* attributes against the panel's default CSS-defined width (40%), + // without changing anything visually — applyListPanelWidth just re-expresses the current rendered width + // as an explicit pixel flex-basis, which future drags/keypresses then adjust from. Needed so a keyboard + // user tabbing to the divider without ever dragging it still gets correct aria-valuenow/min/max. + $(document).on('shown.bs.modal', '#sendEmailNotificationModal', function () { + const $list = $('#bulkEmailListPanel'); + if ($list.length) { + applyListPanelWidth($list.outerWidth()); + } + }); + + // This script is bundled once per page load, but the modal's DOM (including #bulkEmailEditPanelWidget) + // is destroyed and freshly re-rendered by ABP's ModalManager on every open. Reset module state on close + // so a stale selectedApplicationId or an in-flight request from a previous open doesn't leak into the + // next one — invalidatePendingPanelRequest() bumps the version counter (so any still-pending response, + // for either async leg of loadDraftIntoPanel, gets ignored on arrival) and aborts the refresh request. + $(document).on('hidden.bs.modal', '#sendEmailNotificationModal', function () { + selectedApplicationId = null; + invalidatePendingPanelRequest(); + bulkEmailSendConfirmed = false; + $('#DetailsViewApplicationId').val(''); + }); +}); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationSummaryModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationSummaryModal.cshtml new file mode 100644 index 0000000000..f1bc54e27f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationSummaryModal.cshtml @@ -0,0 +1,50 @@ +@page +@model Unity.GrantManager.Web.Pages.BulkEmailNotifications.SendEmailNotificationSummaryModalModel +@using Microsoft.Extensions.Localization +@using Unity.GrantManager.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@inject IStringLocalizer L + +@{ + Layout = null; +} + + + + Send Email Summary + + +
+ + + + + + + + @for (var i = 0; i < Model.BulkEmailNotificationResults?.Count; i++) + { + + + + + + } + +
Submission #:Status:
+ @if (Model.BulkEmailNotificationResults[i].IsSuccess) + { + + } + else + { + + } + + + + +
+
+
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationSummaryModal.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationSummaryModal.cshtml.cs new file mode 100644 index 0000000000..5118e7b1ce --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/SendEmailNotificationSummaryModal.cshtml.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Unity.GrantManager.GrantApplications; + +namespace Unity.GrantManager.Web.Pages.BulkEmailNotifications +{ + public class SendEmailNotificationSummaryModalModel : PageModel + { + [BindProperty] + public List? BulkEmailNotificationResults { get; set; } + + public void OnGet(string summaryJson) + { + var items = new List(); + + var result = JsonSerializer.Deserialize(summaryJson); + + foreach (var item in result?.Successes ?? []) + { + items.Add(new BulkEmailNotificationItemResult + { + ReferenceNo = item, + Message = "Queued for delivery", + IsSuccess = true + }); + } + + foreach (var item in result?.Failures ?? []) + { + items.Add(new BulkEmailNotificationItemResult + { + ReferenceNo = item.Key, + Message = item.Value, + IsSuccess = false + }); + } + + BulkEmailNotificationResults = [.. items.OrderBy(s => s.ReferenceNo)]; + } + + public class BulkEmailNotificationItemResult + { + public string ReferenceNo { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public bool IsSuccess { get; set; } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ViewModels/BulkEmailNotificationViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ViewModels/BulkEmailNotificationViewModel.cs new file mode 100644 index 0000000000..19fd620d02 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ViewModels/BulkEmailNotificationViewModel.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; + +namespace Unity.GrantManager.Web.Pages.BulkEmailNotifications.ViewModels +{ + public class BulkEmailNotificationViewModel + { + public BulkEmailNotificationViewModel() + { + Notes = []; + } + + public Guid ApplicationId { get; set; } + public Guid? EmailId { get; set; } + public string ReferenceNo { get; set; } = string.Empty; + public string? ApplicantName { get; set; } = string.Empty; + public string FormName { get; set; } = string.Empty; + public string ApplicationStatus { get; set; } = string.Empty; + + [DisplayName("Approved Amount")] + public decimal ApprovedAmount { get; set; } + + [DisplayName("Decision Date")] + public DateTime? DecisionDate { get; set; } + + [DisplayName("Created By")] + public string? CreatedByName { get; set; } + + [DisplayName("Last Modified")] + public DateTime? LastModified { get; set; } + + public string? EmailSubject { get; set; } + public bool IsValid { get; set; } + public List Notes { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ViewModels/EmailNotificationNoteViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ViewModels/EmailNotificationNoteViewModel.cs new file mode 100644 index 0000000000..b4e4967383 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/BulkEmailNotifications/ViewModels/EmailNotificationNoteViewModel.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Localization; +using System.Collections.Generic; + +namespace Unity.GrantManager.Web.Pages.BulkEmailNotifications.ViewModels +{ + public class EmailNotificationNoteViewModel + { + public EmailNotificationNoteViewModel(string key, bool active, string description, bool isError) + { + Key = key; + Active = active; + Description = description; + IsError = isError; + } + + public string Key { get; set; } + public bool Active { get; set; } + public string Description { get; set; } + public bool IsError { get; set; } + + public static List CreateNotesList(IStringLocalizer localizer) + { + return + [ + new("NO_DRAFT_FOUND", false, localizer.GetString("SendEmailNotificationRequest:NoDraftFound"), true), + new("MULTIPLE_DRAFTS_FOUND", false, localizer.GetString("SendEmailNotificationRequest:MultipleDraftsFound"), true), + new("MISSING_SUBJECT", false, localizer.GetString("SendEmailNotificationRequest:MissingSubject"), true), + new("MISSING_TO_ADDRESS", false, localizer.GetString("SendEmailNotificationRequest:MissingToAddress"), true), + new("MISSING_FROM_ADDRESS", false, localizer.GetString("SendEmailNotificationRequest:MissingFromAddress"), true), + new("MISSING_BODY", false, localizer.GetString("SendEmailNotificationRequest:MissingBody"), true) + ]; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml index 952ad6afb9..17c22b8b7e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml @@ -7,6 +7,8 @@ @{ var code = Model.HttpStatusCode; var isWrongTenant = code == 409; + var isPossibleTenantMismatch = code == 404 && !string.IsNullOrEmpty(Model.EntityType); + var isApplicant = string.Equals(Model.EntityType, "Applicant", StringComparison.OrdinalIgnoreCase); string title; string message; @@ -14,8 +16,8 @@ switch (code) { case 404: - title = "Page Not Found"; - message = "The page you are looking for does not exist or may have been moved."; + title = isPossibleTenantMismatch ? L["WrongTenantError:Title"].Value : "Page Not Found"; + message = isPossibleTenantMismatch ? string.Empty : "The page you are looking for does not exist or may have been moved."; break; case 403: title = "Access Denied"; @@ -42,7 +44,15 @@ @if (isWrongTenant) {

- @L["WrongTenantError:ApplicationTenant", Model.ApplicationTenantName]
+ @(isApplicant ? L["WrongTenantError:ApplicantTenant", Model.ApplicationTenantName] : L["WrongTenantError:ApplicationTenant", Model.ApplicationTenantName])
+ @L["WrongTenantError:CurrentTenant", Model.CurrentTenantName] +

+

@L["WrongTenantError:Instructions"]

+ } + else if (isPossibleTenantMismatch) + { +

+ @(isApplicant ? L["WrongTenantError:ApplicantNotFoundPossibleTenant"] : L["WrongTenantError:ApplicationNotFoundPossibleTenant"])
@L["WrongTenantError:CurrentTenant", Model.CurrentTenantName]

@L["WrongTenantError:Instructions"]

diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs index aefc0dae49..fa3dd32a6f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Error.cshtml.cs @@ -7,11 +7,13 @@ public class ErrorModel : PageModel public int HttpStatusCode { get; private set; } public string? ApplicationTenantName { get; private set; } public string? CurrentTenantName { get; private set; } + public string? EntityType { get; private set; } - public void OnGet(int httpStatusCode = 0, string? applicationTenantName = null, string? currentTenantName = null) + public void OnGet(int httpStatusCode = 0, string? applicationTenantName = null, string? currentTenantName = null, string? entityType = null) { HttpStatusCode = httpStatusCode;//HTTP Status Code ApplicationTenantName = applicationTenantName; CurrentTenantName = currentTenantName; + EntityType = entityType; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 40884fc686..b4e8413905 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -368,7 +368,7 @@ @if (notificationsFeatureEnabled && readEmailGranted) {
-
+
@await Component.InvokeAsync("EmailsWidget", new { applicationId = Model.ApplicationId, currentUserId = Model.CurrentUserId })
Email History
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs index 223565f1e8..f29cb1774d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml.cs @@ -20,6 +20,7 @@ using Unity.Modules.Shared.Correlation; using Unity.Modules.Shared.Specializations; using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; +using Volo.Abp.Domain.Entities; using Volo.Abp.Features; using Volo.Abp.TenantManagement; using Volo.Abp.Users; @@ -128,6 +129,7 @@ public async Task OnGetAsync() return RedirectToPage("/Error", new { httpStatusCode = 409, + entityType = "Application", applicationTenantName = applicationTenant?.Name ?? TenantId.Value.ToString(), currentTenantName = CurrentTenant.Name ?? "Host" }); @@ -138,7 +140,20 @@ public async Task OnGetAsync() ViewData["ActiveNavHref"] = "/TenantManagement/Onboarding"; } - ApplicationFormSubmission applicationFormSubmission = await _grantApplicationAppService.GetFormSubmissionByApplicationId(ApplicationId); + ApplicationFormSubmission applicationFormSubmission; + try + { + applicationFormSubmission = await _grantApplicationAppService.GetFormSubmissionByApplicationId(ApplicationId); + } + catch (EntityNotFoundException) + { + return RedirectToPage("/Error", new + { + httpStatusCode = 404, + entityType = "Application", + currentTenantName = CurrentTenant.Name ?? "Host" + }); + } ZoneStateSet = await _zoneManagementAppService.GetZoneStateSetAsync(applicationFormSubmission.ApplicationFormId); var formVersion = applicationFormSubmission.ApplicationFormVersionId.HasValue diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-api.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-api.js index 1d592800e7..ba4d4fed47 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-api.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-generation-api.js @@ -38,6 +38,24 @@ 'application/json' ); }, + queueFormMapping(applicationId, applicationFormVersionId) { + return request( + `/api/app/ai/generation/form-mapping?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(applicationFormVersionId)}`, + 'POST' + ); + }, + queueFormWorksheet(applicationId, applicationFormVersionId) { + return request( + `/api/app/ai/generation/form-worksheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(applicationFormVersionId)}`, + 'POST' + ); + }, + queueFormScoresheet(applicationId, applicationFormVersionId) { + return request( + `/api/app/ai/generation/form-scoresheet?applicationId=${encodeURIComponent(applicationId)}&applicationFormVersionId=${encodeURIComponent(applicationFormVersionId)}`, + 'POST' + ); + }, getStatus(applicationId, operationType) { return request( `/api/app/ai/generation/status?applicationId=${encodeURIComponent(applicationId)}&operationType=${encodeURIComponent(operationType)}`, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs index 18a1e5edfc..a8ed290238 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs @@ -28,6 +28,13 @@ public override void ConfigureBundle(BundleConfigurationContext context) .AddIfNotContains("/Pages/BulkApprovals/ApproveApplicationsModal.css"); context.Files .AddIfNotContains("/Pages/BulkActions/BulkPublishApplications.css"); + context.Files + .AddIfNotContains("/Pages/BulkEmailNotifications/SendEmailNotificationModal.css"); + // The bulk-send modal mounts EmailsWidget dynamically (via abp.WidgetManager) for the selected + // application, rather than server-rendering it as part of this page — so its own [Widget] bundle + // never gets a chance to auto-register here. Pull it in explicitly. + context.Files + .AddIfNotContains("/Views/Shared/Components/EmailsWidget/Default.css"); } } @@ -52,6 +59,14 @@ public override void ConfigureBundle(BundleConfigurationContext context) .AddIfNotContains("/Pages/BulkApprovals/ApproveApplicationsModal.js"); context.Files .AddIfNotContains("/Pages/BulkActions/BulkPublishApplications.js"); + context.Files + .AddIfNotContains("/Pages/BulkEmailNotifications/SendEmailNotificationModal.js"); + // Same reasoning as the style bundle above: EmailsWidget is only ever mounted dynamically inside + // the bulk-send modal on this page, so its script needs to be pulled in explicitly rather than + // relying on the widget's own [Widget(AutoInitialize = true)] bundle registration, which only + // fires for widgets that are actually server-rendered as part of a page's initial HTML. + context.Files + .AddIfNotContains("/Views/Shared/Components/EmailsWidget/Default.js"); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml index 7843f10950..14f3087489 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml @@ -4,14 +4,19 @@ @using Unity.GrantManager.Permissions; @using Unity.GrantManager.Payments; @using Unity.Modules.Shared; +@using Unity.Notifications.Permissions; @using Unity.Payments.Permissions; @using Volo.Abp.Authorization.Permissions; @using Volo.Abp.Features; +@using Volo.Abp.Users; +@using Microsoft.Extensions.Configuration; @inject IStringLocalizer L @inject IAuthorizationService AuthorizationService @inject IFeatureChecker FeatureChecker @inject IPermissionChecker PermissionChecker +@inject ICurrentUser CurrentUser +@inject IConfiguration Configuration
@@ -97,6 +102,30 @@ button-type="Secondary" /> } + @* The bulk-send modal's right panel reuses EmailsWidget, whose edit fieldset/Save button/attachment + handling all require Notifications.Email.Send (see EmailsWidget/Default.cshtml and + EmailLogAttachmentAppService's class-level [Authorize]). SendBulk and Send are sibling permissions, + not nested, so a user could otherwise be granted SendBulk alone and open this feature into a + read-only, non-functional right panel. Require both. *@ + @if (await FeatureChecker.IsEnabledAsync("Unity.Notifications") + && await PermissionChecker.IsGrantedAsync(NotificationsPermissions.Email.SendBulk) + && await PermissionChecker.IsGrantedAsync(NotificationsPermissions.Email.Send)) + { + + @* EmailsWidget (reused inside the bulk-send modal's edit panel) expects these ambient hidden + fields to exist on the host page — normally provided by GrantApplications/Details.cshtml. + DetailsViewApplicationId's value is updated per row selection by SendEmailNotificationModal.js. *@ + + + + + + } + @if (await FeatureChecker.IsEnabledAsync(PaymentConsts.UnityPaymentsFeature) && await PermissionChecker.IsGrantedAsync(PaymentsPermissions.Payments.RequestPayment)) { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.js index f370959240..5d44c2d7e6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.js @@ -16,6 +16,12 @@ $(function () { let approveApplicationsSummaryModal = new abp.ModalManager({ viewUrl: 'BulkApprovals/ApproveApplicationsSummaryModal' }); + let sendEmailNotificationModal = new abp.ModalManager({ + viewUrl: 'BulkEmailNotifications/SendEmailNotificationModal' + }); + let sendEmailNotificationSummaryModal = new abp.ModalManager({ + viewUrl: 'BulkEmailNotifications/SendEmailNotificationSummaryModal' + }); let tagApplicationModal = new abp.ModalManager({ viewUrl: 'ApplicationTags/ApplicationTagsSelectionModal', }); @@ -328,6 +334,48 @@ $(function () { }); //#endregion Batch Approval + //#region Send Email Notification + $('#sendEmailNotification').on("click", function () { + // Store application IDs in distributed cache to avoid URL length limits + unity.grantManager.applications.applicationBulkActions + .storeApplicationIds({ applicationIds: selectedApplicationIds }) + .then(function(response) { + // Open modal with cache key instead of application IDs array + sendEmailNotificationModal.open({ + cacheKey: response.cacheKey + }); + }) + .catch(function(error) { + abp.notify.error('Failed to prepare bulk email. Please try again.'); + console.error('Error storing application IDs:', error); + }); + }); + sendEmailNotificationModal.onResult(function (_, response) { + let transformedFailures = response.responseText.failures.map(failure => { + return { + Key: failure.key, + Value: failure.value + }; + }); + let successCount = response.responseText.successes.length; + if (successCount > 0) { + abp.notify.success( + successCount === 1 + ? 'The email has been successfully queued for delivery.' + : successCount + ' emails have been successfully queued for delivery.', + 'Send Email Notification' + ); + } + let summaryJson = JSON.stringify( + { + Successes: response.responseText.successes, + Failures: transformedFailures + }); + sendEmailNotificationSummaryModal.open({ summaryJson: summaryJson }); + PubSub.publish("refresh_application_list"); + }); + //#endregion Send Email Notification + //#region Selection Events PubSub.subscribe("select_application", (msg, data) => { selectedApplicationIds.push(data.id); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidget.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidget.cs index d24d12afb0..46f7d89d76 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidget.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidget.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; using Unity.GrantManager.ApplicationForms; using Unity.GrantManager.Applications; using Volo.Abp.AspNetCore.Mvc; @@ -29,6 +30,20 @@ public async Task InvokeAsync(string? configType, Applicat { await Task.CompletedTask; + var externalLinks = applicationForm?.ExternalLinks ?? []; + var renewalLink = externalLinks.FirstOrDefault(x => x.ExternalLinkType == ExternalLinkType.Renewal); + var relatedLinks = externalLinks + .Where(x => x.ExternalLinkType == ExternalLinkType.Related) + .OrderBy(x => x.Order == -1 ? int.MaxValue : x.Order) + .Select(x => new RelatedLinkItemViewModel + { + Uri = x.Uri, + Title = x.Title, + Description = x.Description, + Published = x.Published + }) + .ToList(); + var viewModel = new ApplicationFormConfigWidgetViewModel() { ConfigType = configType, @@ -37,7 +52,12 @@ public async Task InvokeAsync(string? configType, Applicat ElectoralDistrictAddressTypes = LoadElectoralAddressOptions(), Prefix = applicationForm?.Prefix, SuffixType = applicationForm?.SuffixType, - SuffixTypes = LoadSuffixOptions() + SuffixTypes = LoadSuffixOptions(), + RenewalLinkUri = renewalLink?.Uri ?? string.Empty, + RenewalLinkTitle = renewalLink?.Title ?? string.Empty, + RenewalLinkPublished = renewalLink?.Published ?? false, + ApplicantMessage = applicationForm?.ApplicantMessage ?? string.Empty, + RelatedLinks = relatedLinks }; return View(viewModel); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidgetViewModel.cs index d44e05e758..a893aa5574 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/ApplicationFormConfigWidgetViewModel.cs @@ -18,6 +18,12 @@ public class ApplicationFormConfigWidgetViewModel public SuffixConfigType? SuffixType { get; set; } public List SuffixTypes { get; set; } = []; + public string RenewalLinkUri { get; set; } = string.Empty; + public string RenewalLinkTitle { get; set; } = string.Empty; + public bool RenewalLinkPublished { get; set; } + public string ApplicantMessage { get; set; } = string.Empty; + public List RelatedLinks { get; set; } = []; + public static List FormatOptionsList(Dictionary optionsList) { List optionsFormattedList = new(); @@ -29,3 +35,4 @@ public static List FormatOptionsList(Dictionary } } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.cshtml index d1c3780fea..2b72ebd1d8 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.cshtml @@ -127,5 +127,130 @@
+
+
+
+
@L["ApplicationForms.Configuration:ApplicantPortalLinks"].Value
+
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+
+
+
+
+ + + +
+
+ NOTE: @L["ApplicationForms.Configuration.Notes:LinkVisibilityRequiresUrl"].Value +
+ +
+ @L["ApplicationForms.Configuration:OtherLinks"].Value +
+ + +
+ +
+ NOTE: @L["ApplicationForms.Configuration.Notes:MaxRelatedLinks"].Value +
+
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css index 4a9ddf92ef..f35b14fc2e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css @@ -37,3 +37,24 @@ border-radius: 0.25rem; border: 1px solid #e9ecef; } + +.field-error { + display: block; + min-height: 1.1rem; + font-size: 0.8rem; +} + +.related-link-row { + align-items: flex-start; + padding-bottom: 0.25rem; +} + +.related-link-row .form-control.is-invalid, +#renewalLinkUri.is-invalid { + border-color: var(--lpx-danger); +} + +#btn-add-related-link:disabled { + opacity: 0.6; +} + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.js index 1b72bac2d6..e27eae4740 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.js @@ -14,12 +14,246 @@ const cancelButton = document.getElementById('btn-cancel-other-config'); const backButton = document.getElementById('btn-back-other-config'); + const renewalLinkUri = document.getElementById('renewalLinkUri'); + const renewalLinkTitle = document.getElementById('renewalLinkTitle'); + const renewalLinkPublished = document.getElementById('renewalLinkPublished'); + const applicantMessage = document.getElementById('applicantMessage'); + const relatedLinksContainer = document.getElementById('relatedLinksContainer'); + const addRelatedLinkButton = document.getElementById('btn-add-related-link'); + + const MAX_RELATED_LINKS = 8; + const URL_PATTERN = /^https?:\/\/\S+$/i; + const EXTERNAL_LINK_TYPE_RENEWAL = 2; + const EXTERNAL_LINK_TYPE_RELATED = 1; + + const l = abp.localization.getResource('GrantManager'); + + function isValidUrl(value) { + return !!value && URL_PATTERN.test(value.trim()); + } + + function getFieldErrorElement(input) { + return input.parentElement.querySelector('.field-error'); + } + + function clearFieldError(input) { + const errorEl = getFieldErrorElement(input); + if (errorEl) { + errorEl.textContent = ''; + } + input.classList.remove('is-invalid'); + } + + function setFieldError(input, message) { + const errorEl = getFieldErrorElement(input); + if (errorEl) { + errorEl.textContent = message; + } + input.classList.add('is-invalid'); + } + + function collectRelatedLinkRows() { + return Array.from(relatedLinksContainer.querySelectorAll('.related-link-row')); + } + + function collectRelatedLinksSnapshot() { + return collectRelatedLinkRows().map(function (row) { + return { + uri: row.querySelector('.related-link-uri').value, + title: row.querySelector('.related-link-title').value, + description: row.querySelector('.related-link-description').value, + published: row.querySelector('.related-link-published').checked + }; + }); + } + + function updateAddButtonState() { + addRelatedLinkButton.disabled = collectRelatedLinkRows().length >= MAX_RELATED_LINKS; + } + + function createRelatedLinkRow(data) { + data = data || { uri: '', title: '', description: '', published: false }; + + const row = document.createElement('div'); + row.className = 'related-link-row row mt-2'; + + const uriCol = document.createElement('div'); + uriCol.className = 'col-12 col-md-4'; + const uriInput = document.createElement('input'); + uriInput.type = 'url'; + uriInput.className = 'form-control related-link-uri'; + uriInput.maxLength = 2048; + uriInput.placeholder = 'https://...'; + uriInput.value = data.uri; + const uriError = document.createElement('span'); + uriError.className = 'field-error text-danger small'; + uriCol.appendChild(uriInput); + uriCol.appendChild(uriError); + + const titleCol = document.createElement('div'); + titleCol.className = 'col-12 col-md-3'; + const titleInput = document.createElement('input'); + titleInput.type = 'text'; + titleInput.className = 'form-control related-link-title'; + titleInput.maxLength = 255; + titleInput.placeholder = l('ApplicationForms.Configuration:LinkDisplayName'); + titleInput.value = data.title; + titleCol.appendChild(titleInput); + + const descCol = document.createElement('div'); + descCol.className = 'col-12 col-md-3'; + const descInput = document.createElement('input'); + descInput.type = 'text'; + descInput.className = 'form-control related-link-description'; + descInput.maxLength = 512; + descInput.placeholder = l('ApplicationForms.Configuration:LinkDescription'); + descInput.value = data.description; + descCol.appendChild(descInput); + + const toggleCol = document.createElement('div'); + toggleCol.className = 'col-6 col-md-1 d-flex align-items-center'; + const switchWrapper = document.createElement('div'); + switchWrapper.className = 'form-check unt-form-switch form-switch'; + const toggleInput = document.createElement('input'); + toggleInput.type = 'checkbox'; + toggleInput.className = 'form-check-input related-link-published'; + toggleInput.setAttribute('aria-label', l('ApplicationForms.Configuration:ShowOtherLinksInPortal')); + toggleInput.style.cursor = 'pointer'; + toggleInput.checked = data.published; + switchWrapper.appendChild(toggleInput); + toggleCol.appendChild(switchWrapper); + + const removeCol = document.createElement('div'); + removeCol.className = 'col-6 col-md-1 d-flex align-items-center'; + const removeButton = document.createElement('button'); + removeButton.type = 'button'; + removeButton.className = 'btn btn-sm btn-outline-danger btn-remove-related-link'; + removeButton.setAttribute('aria-label', 'Remove Link'); + const removeIcon = document.createElement('i'); + removeIcon.className = 'fl fl-trash'; + removeButton.appendChild(removeIcon); + removeCol.appendChild(removeButton); + + row.appendChild(uriCol); + row.appendChild(titleCol); + row.appendChild(descCol); + row.appendChild(toggleCol); + row.appendChild(removeCol); + + return row; + } + + relatedLinksContainer.addEventListener('click', function (event) { + const removeButton = event.target.closest('.btn-remove-related-link'); + if (!removeButton || !relatedLinksContainer.contains(removeButton)) { + return; + } + + const row = removeButton.closest('.related-link-row'); + if (!row) { + return; + } + + row.remove(); + updateAddButtonState(); + saveButton.disabled = false; + cancelButton.disabled = false; + }); + + function rebuildRelatedLinkRows(links) { + relatedLinksContainer.innerHTML = ''; + links.forEach(function (link) { + relatedLinksContainer.appendChild(createRelatedLinkRow(link)); + }); + updateAddButtonState(); + } + + addRelatedLinkButton.addEventListener('click', function () { + if (collectRelatedLinkRows().length >= MAX_RELATED_LINKS) { + return; + } + relatedLinksContainer.appendChild(createRelatedLinkRow()); + updateAddButtonState(); + saveButton.disabled = false; + cancelButton.disabled = false; + }); + + updateAddButtonState(); + + function validateExternalLinksConfig() { + let isValid = true; + + clearFieldError(renewalLinkUri); + const renewalUriValue = renewalLinkUri.value.trim(); + if (renewalLinkPublished.checked && !isValidUrl(renewalUriValue)) { + setFieldError(renewalLinkUri, l('ApplicationForms.Configuration.Errors:RenewalLinkRequiredForVisibility')); + isValid = false; + } else if (renewalUriValue && !isValidUrl(renewalUriValue)) { + setFieldError(renewalLinkUri, l('ApplicationForms.Configuration.Errors:InvalidUrl')); + isValid = false; + } + + const rows = collectRelatedLinkRows(); + if (rows.length > MAX_RELATED_LINKS) { + abp.notify.error(l('ApplicationForms.Configuration.Errors:MaxRelatedLinksReached')); + isValid = false; + } + + rows.forEach(function (row) { + const uriInput = row.querySelector('.related-link-uri'); + const publishedInput = row.querySelector('.related-link-published'); + clearFieldError(uriInput); + const value = uriInput.value.trim(); + if (publishedInput.checked && !isValidUrl(value)) { + setFieldError(uriInput, l('ApplicationForms.Configuration.Errors:OtherLinkRequiredForVisibility')); + isValid = false; + } else if (value && !isValidUrl(value)) { + setFieldError(uriInput, l('ApplicationForms.Configuration.Errors:InvalidUrl')); + isValid = false; + } + }); + + return isValid; + } + + function buildExternalLinksConfigPayload() { + const renewalUriValue = renewalLinkUri.value.trim(); + + return { + renewalLink: renewalUriValue ? { + uri: renewalUriValue, + title: renewalLinkTitle.value, + published: renewalLinkPublished.checked, + externalLinkType: EXTERNAL_LINK_TYPE_RENEWAL, + order: 0 + } : null, + relatedLinks: collectRelatedLinkRows() + .map(function (row, index) { + return { + uri: row.querySelector('.related-link-uri').value.trim(), + title: row.querySelector('.related-link-title').value, + description: row.querySelector('.related-link-description').value, + published: row.querySelector('.related-link-published').checked, + externalLinkType: EXTERNAL_LINK_TYPE_RELATED, + order: index + }; + }) + .filter(function (link) { return link.uri; }), + applicantMessage: applicantMessage.value + }; + } + // Store last saved values let lastSavedValues = { directApproval: directApproval.checked, electoralDistrictAddressType: electoralDistrictAddressType.value, prefix: prefix.value, - suffixType: suffixType.value + suffixType: suffixType.value, + renewalLinkUri: renewalLinkUri.value, + renewalLinkTitle: renewalLinkTitle.value, + renewalLinkPublished: renewalLinkPublished.checked, + applicantMessage: applicantMessage.value, + relatedLinks: collectRelatedLinksSnapshot() }; // Initially disable the save and cancel buttons @@ -81,7 +315,13 @@ electoralDistrictAddressType.value = lastSavedValues.electoralDistrictAddressType; prefix.value = lastSavedValues.prefix; suffixType.value = lastSavedValues.suffixType; - + renewalLinkUri.value = lastSavedValues.renewalLinkUri; + renewalLinkTitle.value = lastSavedValues.renewalLinkTitle; + renewalLinkPublished.checked = lastSavedValues.renewalLinkPublished; + applicantMessage.value = lastSavedValues.applicantMessage; + rebuildRelatedLinkRows(lastSavedValues.relatedLinks); + clearFieldError(renewalLinkUri); + // Update preview after restoring values updateUnityIdPreview(); @@ -98,17 +338,15 @@ let isSaving = false; saveButton.addEventListener('click', function (event) { - console.log('Save button clicked'); - console.log(event); - console.log( - electoralDistrictAddressType.value, - prefix.value, - suffixType.value - ); if (isSaving || saveButton.disabled) { event.preventDefault(); return; } + + if (!validateExternalLinksConfig()) { + return; + } + isSaving = true; saveButton.disabled = true; // Disable immediately to prevent double click cancelButton.disabled = true; @@ -125,21 +363,39 @@ }), contentType: 'application/json', }) - .done(function () { - // Update last saved values after successful save + .then(function () { + // Only save external links config once other-config succeeds, + // keeping the two saves sequential. + return abp.ajax({ + url: `/api/app/application-form/${applicationFormId}/external-links-config`, + type: 'PATCH', + data: JSON.stringify(buildExternalLinksConfigPayload()), + contentType: 'application/json', + }); + }) + .then(function () { + // Clear dirty state only after both saves succeed. lastSavedValues = { directApproval: directApproval.checked, electoralDistrictAddressType: electoralDistrictAddressType.value, prefix: prefix.value, - suffixType: suffixType.value + suffixType: suffixType.value, + renewalLinkUri: renewalLinkUri.value, + renewalLinkTitle: renewalLinkTitle.value, + renewalLinkPublished: renewalLinkPublished.checked, + applicantMessage: applicantMessage.value, + relatedLinks: collectRelatedLinksSnapshot() }; abp.notify.success('Other configuration saved successfully.'); + resetFormState(); }) - .fail(function (error) { - abp.notify.error('Failed to save other configuration.'); + .catch(function () { + // Keep the form dirty so the user can retry after a partial failure. + abp.notify.error('Failed to save configuration.'); + saveButton.disabled = false; + cancelButton.disabled = false; }) - .always(function () { - resetFormState(); + .then(function () { isSaving = false; }); }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/RelatedLinkItemViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/RelatedLinkItemViewModel.cs new file mode 100644 index 0000000000..57a9e0a9c6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/RelatedLinkItemViewModel.cs @@ -0,0 +1,9 @@ +namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicationFormConfigWidget; + +public class RelatedLinkItemViewModel +{ + public string Uri { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public bool Published { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs index 5650982d31..819485be00 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewComponent.cs @@ -33,7 +33,11 @@ public class CustomFieldsViewComponent(IApplicationFormVersionAppService applica public string AccountCodeList { get; set; } = string.Empty; - public async Task InvokeAsync(string? formVersionId, string formName) + public async Task InvokeAsync( + string? formVersionId, + string formName, + bool canViewScoresheet = false, + bool canGenerateScoresheet = false) { var model = new CustomFieldsViewModel { }; model.ChefsFormVersionId = Guid.Parse(formVersionId ?? Guid.Empty.ToString()); @@ -67,6 +71,8 @@ public async Task InvokeAsync(string? formVersionId, strin ? await applicationFormRepository.FindAsync(x => x.Id == applicationFormId.Value) : null; model.ScoresheetId = applicationForm?.ScoresheetId; + model.CanViewScoresheet = canViewScoresheet; + model.CanGenerateScoresheet = canGenerateScoresheet; var scoresheets = await scoresheetAppService.GetAllPublishedScoresheetsAsync(); model.ScoresheetOptionsList = []; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs index c0e544c163..85c8b68a7d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/CustomFieldsViewModel.cs @@ -38,6 +38,8 @@ public class CustomFieldsViewModel public List? CustomTabLinks { get; set; } public bool HasPendingAiWorksheet { get; set; } + public bool CanViewScoresheet { get; set; } + public bool CanGenerateScoresheet { get; set; } [Display(Name = "")] public Guid? ScoresheetId { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml index 4f5e8c5a80..5ad74efbf3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml @@ -1,6 +1,4 @@ @using Unity.GrantManager.Web.Views.Shared.Components.CustomFields @* Suppress:S1128 *@ -@using Unity.AI.Permissions -@inject Volo.Abp.Authorization.Permissions.IPermissionChecker PermissionChecker @model CustomFieldsViewModel @{ @@ -12,10 +10,9 @@
Scoresheet & Worksheets Configuration
- @if (await PermissionChecker.IsGrantedAsync(AIPermissions.FormScoresheet.View)) + @if (Model.CanGenerateScoresheet) { } + @if (Model.CanViewScoresheet) + { + + } .col-auto, -#applicationEmailsWidget .row.align-items-center > .col-auto { +.emails-widget-scope .row.align-items-start > .col-auto, +.emails-widget-scope .row.align-items-center > .col-auto { flex: 0 0 46px !important; width: 46px !important; } -#applicationEmailsWidget .col { +.emails-widget-scope .col { flex: 1 0 0%; width: 100%; } -#applicationEmailsWidget .email-to-container { +.emails-widget-scope .email-to-container { position: relative; } -#applicationEmailsWidget .email-to-container .email-input { +.emails-widget-scope .email-to-container .email-input { padding-right: 60px; } -#applicationEmailsWidget .email-bcc-button { +.emails-widget-scope .email-bcc-button { position: absolute; right: 8px; font-size: 0.75rem; @@ -74,28 +74,28 @@ z-index: 10; } -#applicationEmailsWidget #bcc-input-row { +.emails-widget-scope #bcc-input-row { display: none; } -#applicationEmailsWidget #bcc-input-row.show { +.emails-widget-scope #bcc-input-row.show { display: flex; } -#applicationEmailsWidget #scheduled-delay-section #send-on-display:empty { +.emails-widget-scope #scheduled-delay-section #send-on-display:empty { display: none; } -#applicationEmailsWidget #scheduled-delay-section #send-on-display:empty ~ #btn-clear-schedule { +.emails-widget-scope #scheduled-delay-section #send-on-display:empty ~ #btn-clear-schedule { display: none !important; } -#applicationEmailsWidget .email-bcc-button.hide { +.emails-widget-scope .email-bcc-button.hide { display: none; } -#applicationEmailsWidget .from-field-container { +.emails-widget-scope .from-field-container { align-items: center; display: flex; height: 36px; @@ -107,7 +107,7 @@ margin-top: 4px; } -#applicationEmailsWidget .from-field-container .btn-send-from { +.emails-widget-scope .from-field-container .btn-send-from { position: absolute; right: 0; top: 0; @@ -120,7 +120,7 @@ justify-content: center; } -#applicationEmailsWidget .from-label { +.emails-widget-scope .from-label { font-size: 1rem !important; font-weight: 500; white-space: nowrap; @@ -130,7 +130,7 @@ min-width: 40px; } -#applicationEmailsWidget .from-field-container .from-input { +.emails-widget-scope .from-field-container .from-input { min-width: 150px; width: 100%; height: 36px; @@ -142,15 +142,16 @@ margin: 0px !important; } -#applicationEmailsWidget .from-field-container .mb-3 { +.emails-widget-scope .from-field-container .mb-3 { display: inline-block; vertical-align: middle; margin-top: 9px; margin-bottom: 0px; flex-grow: 1; + position: relative; } -#applicationEmailsWidget .email-form label { +.emails-widget-scope .email-form label { color: var(--bc-colors-grey-text-300); white-space: nowrap; overflow: hidden; @@ -161,14 +162,14 @@ } /* Override margin-top for labels in row layouts (To, Cc, Bcc) */ -#applicationEmailsWidget .row.align-items-start label, -#applicationEmailsWidget .row.align-items-center label { +.emails-widget-scope .row.align-items-start label, +.emails-widget-scope .row.align-items-center label { margin-top: 15px; font-size: 14px; font-weight: 500; } -#applicationEmailsWidget .form-label { +.emails-widget-scope .form-label { display: inline-block; color: var(--bc-colors-grey-text-300, #666666); font-size: 1rem !important; @@ -179,53 +180,53 @@ } /* Specific alignment fix for labels in horizontal layouts */ -#applicationEmailsWidget #scheduled-label-container { +.emails-widget-scope #scheduled-label-container { display: none; padding-top: 8px; /* Adjust this value to perfectly center label with the first line of input */ } -#applicationEmailsWidget #scheduled-label-container.show { +.emails-widget-scope #scheduled-label-container.show { display: inline; } /* Ensure the parent row aligns to the top to prevent shifting when errors appear */ -#applicationEmailsWidget .row.align-items-start { +.emails-widget-scope .row.align-items-start { align-items: flex-start !important; } -#applicationEmailsWidget .email-input:hover { +.emails-widget-scope .email-input:hover { box-shadow: none !important; } -#applicationEmailsWidget .email-input.selected { +.emails-widget-scope .email-input.selected { border: 0 !important; outline: 0 !important; border-bottom: 0.5px solid #D6D6D6 !important; border-radius: 0px !important; } -#applicationEmailsWidget .email-button-container { +.emails-widget-scope .email-button-container { display: flex; align-items: flex-end; justify-content: right; gap: 8px; } -#applicationEmailsWidget .email-input-multiple:hover { +.emails-widget-scope .email-input-multiple:hover { box-shadow: none !important; border-bottom: 1.5px solid var(--bc-colors-grey-text-500) !important; } -#applicationEmailsWidget .email-input::placeholder { +.emails-widget-scope .email-input::placeholder { color: #b9b9b9 !important; } -#applicationEmailsWidget textarea::placeholder { +.emails-widget-scope textarea::placeholder { color: #b9b9b9 !important; } /* Email template select styling */ -#applicationEmailsWidget #EmailTemplate { +.emails-widget-scope #EmailTemplate { margin-right: 8px; margin-left: 8px; margin-top: 2px; @@ -233,23 +234,21 @@ } /* 1. Style the select itself when the empty value is selected */ -#applicationEmailsWidget #EmailTemplate:has(option[value=""]:checked) { +.emails-widget-scope #EmailTemplate:has(option[value=""]:checked) { color: #b9b9b9 !important; } /* 2. Style the placeholder option inside the dropdown list */ -#applicationEmailsWidget #EmailTemplate option[value=""] { +.emails-widget-scope #EmailTemplate option[value=""] { color: #b9b9b9 !important; } /* 3. Ensure valid selections return to your default text color (e.g., dark grey/black) */ -#applicationEmailsWidget #EmailTemplate option:not([value=""]) { +.emails-widget-scope #EmailTemplate option:not([value=""]) { color: #212529; } -#applicationEmailsWidget button { - margin-top: 0px; - margin-bottom: 0px; +.emails-widget-scope button { height: 36px; min-height: 36px; display: inline-flex; @@ -259,9 +258,10 @@ padding-top: 0px; padding-bottom: 0px; box-sizing: border-box; + margin: 2px; } -#user-dropdown > button { +.emails-widget-scope #user-dropdown > button { height: 36px; min-height: 36px; max-height: 36px; @@ -275,12 +275,12 @@ margin-bottom: 0px; } -#applicationEmailsWidget .email-buttons-group { +.emails-widget-scope .email-buttons-group { display: flex; align-items: center; } -#applicationEmailsWidget .email-btn { +.emails-widget-scope .email-btn { margin-top: 10px; padding-left: 14px; padding-right: 14px; @@ -292,17 +292,16 @@ transition: box-shadow .3s; } -#applicationEmailsWidget .email-btn:hover { +.emails-widget-scope .email-btn:hover { box-shadow: -3px 3px 11px -3px rgba(33, 33, 33, .2); } -#applicationEmailsWidget .btn-send-close { - margin-top: 0px; - margin-bottom: 0px; +.emails-widget-scope .btn-send-close { + margin: 4px; } /* Send dropdown button styling */ -#applicationEmailsWidget .btn-group .dropdown-toggle { +.emails-widget-scope .btn-group .dropdown-toggle { padding-left: 1px !important; padding-right: 1px !important; border-left: 1px solid rgba(255, 255, 255, 0.2) !important; @@ -312,46 +311,46 @@ } /* When dropdown is not visible/enabled, apply right border radius to send button */ -#applicationEmailsWidget .btn-group:not(:has(.dropdown-toggle)) #btn-send-top { +.emails-widget-scope .btn-group:not(:has(.dropdown-toggle)) #btn-send-top { border-top-right-radius: 3px !important; border-bottom-right-radius: 3px !important; } -#applicationEmailsWidget .form-select { +.emails-widget-scope .form-select { height: 36px; } -#applicationEmailsWidget .btn-group .dropdown-toggle.show { +.emails-widget-scope .btn-group .dropdown-toggle.show { background-color: inherit !important; color: inherit !important; border-color: inherit !important; } -#applicationEmailsWidget .btn-group .dropdown-toggle::after { +.emails-widget-scope .btn-group .dropdown-toggle::after { display: none; } -#applicationEmailsWidget .btn-group .dropdown-toggle i { +.emails-widget-scope .btn-group .dropdown-toggle i { font-size: 0.85rem; } /* Dropdown menu positioning */ -#applicationEmailsWidget .btn-group .dropdown-menu { +.emails-widget-scope .btn-group .dropdown-menu { left: 0 !important; right: auto !important; margin-top: 0.25rem !important; transform: translate(0px, 36px) !important; } -.dropdown-menu .btn-send-menu, -.dropdown-menu .btn-schedule-send-menu { +.emails-widget-scope .dropdown-menu .btn-send-menu, +.emails-widget-scope .dropdown-menu .btn-schedule-send-menu { display: block; width: 100%; text-align: left; } /* Email buttons row - sticky at top */ -#applicationEmailsWidget #EmailForm .row:has(.email-buttons-container) { +.emails-widget-scope #EmailForm .row:has(.email-buttons-container) { position: sticky; top: 0; background-color: white; @@ -359,12 +358,12 @@ padding-bottom: 0.8rem !important; } -#scheduled-display-container { +.emails-widget-scope #scheduled-display-container { padding-bottom: 10px; } /* Email buttons container */ -.email-buttons-container { +.emails-widget-scope .email-buttons-container { display: flex !important; align-items: center; gap: 0.5rem; @@ -372,22 +371,22 @@ overflow: visible; } -.email-buttons-container abp-modal-footer { +.emails-widget-scope .email-buttons-container abp-modal-footer { display: contents; } -.email-buttons-container .ms-auto { +.emails-widget-scope .email-buttons-container .ms-auto { display: flex; align-items: center; gap: 0.5rem; overflow: visible; } -.email-buttons-container .dropdown-menu { +.emails-widget-scope .email-buttons-container .dropdown-menu { z-index: 1070; } -#applicationEmailsWidget .email-lbl { +.emails-widget-scope .email-lbl { display: block; margin-top: 0.25rem; width: 99%; @@ -398,14 +397,14 @@ font-size: var(--bc-font-size); } -#applicationEmailsWidget .email-lbl-wrapper { +.emails-widget-scope .email-lbl-wrapper { color: #666666; font-size: 0.89rem; font-weight: 400; word-wrap: break-word; } -#applicationEmailsWidget .unity-email-block { +.emails-widget-scope .unity-email-block { background: var(--bc-surface--color--brand--gold--20); padding: 0.75rem 0.5rem 0.5rem 1rem; border-radius: 0.25rem; @@ -415,7 +414,7 @@ width: 90%; } -#applicationEmailsWidget .field-validation-error { +.emails-widget-scope .field-validation-error { display: inline-block; margin-left: 8px; white-space: nowrap; @@ -423,7 +422,7 @@ color: #dc3545; } -#applicationEmailsWidget .input-validation-error { +.emails-widget-scope .input-validation-error { border: 1px solid #dc3545 !important; border-style: solid !important; border-color: #dc3545 !important; @@ -431,23 +430,28 @@ box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 0.2rem rgba(220, 53, 69, 0.5) !important; } -#applicationEmailsWidget .input-validation-error:focus, -#applicationEmailsWidget input.input-validation-error:focus, -#applicationEmailsWidget textarea.input-validation-error:focus, -#applicationEmailsWidget select.input-validation-error:focus { +.emails-widget-scope .input-validation-error:focus, +.emails-widget-scope input.input-validation-error:focus, +.emails-widget-scope textarea.input-validation-error:focus, +.emails-widget-scope select.input-validation-error:focus { border: 1px solid #dc3545 !important; border-color: #dc3545 !important; box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 0.2rem rgba(220, 53, 69, 0.5) !important; } -#applicationEmailsWidget .from-field-container .field-validation-error { +.emails-widget-scope .from-field-container .field-validation-error { position: absolute; - top: 85%; + top: calc(100% + 0.25rem); + left: 0; margin-left: 0; display: inline-block; } -#applicationEmailsWidget .confirmation-label { +.emails-widget-scope #EmailForm .row:has(.from-field-container .field-validation-error) { + padding-bottom: 1.75rem !important; +} + +.emails-widget-scope .confirmation-label { font-size: 19px; font-weight: 600; text-align: center; @@ -460,7 +464,7 @@ color: inherit; } -#confirmation-modal { +.emails-widget-scope #confirmation-modal { z-index: 1; justify-content: center; margin: 1em 1.6em .3em; @@ -475,7 +479,7 @@ word-break: break-word; } -#btn-cancel-email { +.emails-widget-scope #btn-cancel-email { border: 0; border-radius: .25em; background: initial; @@ -485,11 +489,7 @@ margin: 4px; } -#applicationEmailsWidget .btn-send-close { - margin: 4px; -} - -#applicationEmailsWidget #modal-background { +.emails-widget-scope #modal-background { display: none; position: fixed; top: 0; @@ -504,19 +504,19 @@ z-index: 1000; } -#applicationEmailsWidget #modal-content { +.emails-widget-scope #modal-content { display: none; } -#applicationEmailsWidget #spinner-modal { +.emails-widget-scope #spinner-modal { display: none; } -#applicationEmailsWidget .modal-footer { +.emails-widget-scope .modal-footer { justify-content: center; } -#applicationEmailsWidget .modal-content { +.emails-widget-scope .modal-content { border-radius: 5px; -webkit-border-radius: 5px; -moz-border-radius: 5px; @@ -539,31 +539,31 @@ justify-content: center; } -#EmailForm { +.emails-widget-scope #EmailForm { display: none; } -#EmailForm.active { +.emails-widget-scope #EmailForm.active { display: block; } -#applicationEmailsWidget .hide { +.emails-widget-scope .hide { display: none; } -#applicationEmailsWidget .toast-top-center { +.emails-widget-scope .toast-top-center { top: 220px; margin: 0 auto; left: 50%; margin-left: -450px; } -#applicationEmailsWidget #modal-background.active, -#applicationEmailsWidget #modal-content.active { +.emails-widget-scope #modal-background.active, +.emails-widget-scope #modal-content.active { display: block; } -#applicationEmailsWidget #email-spinner { +.emails-widget-scope #email-spinner { width: 40px; height: 40px; position: absolute; @@ -577,28 +577,24 @@ animation: rotateSpinner 800ms linear infinite; } -#applicationEmailsWidget .email-spinner-text { +.emails-widget-scope .email-spinner-text { display: flex; padding-right: 100px; } -#applicationEmailsWidget button { - margin: 2px; -} - -#applicationEmailsWidget button:hover { +.emails-widget-scope button:hover { border: 1px solid var(--bc-colors-blue-primary); } -#applicationEmailsWidget .fl-cancel { +.emails-widget-scope .fl-cancel { color: var(--bc-colors-blue-primary); } -#applicationEmailsWidget .btn-delete-draft { +.emails-widget-scope .btn-delete-draft { background-color: white !important; } -#schedule-modal-backdrop { +.emails-widget-scope #schedule-modal-backdrop { display: none; position: fixed; inset: 0; @@ -606,7 +602,7 @@ z-index: 1060; } -#schedule-send-modal { +.emails-widget-scope #schedule-send-modal { display: none !important; position: fixed; top: 50%; @@ -624,12 +620,12 @@ overflow: auto; } -#schedule-send-modal.active { +.emails-widget-scope #schedule-send-modal.active { display: flex !important; } /* Header */ -.schedule-modal-header { +.emails-widget-scope .schedule-modal-header { display: flex; justify-content: space-between; align-items: center; @@ -638,14 +634,14 @@ border-bottom: 1px solid #dee2e6; } -.schedule-modal-title { +.emails-widget-scope .schedule-modal-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: #212529; } -.btn-close-modal { +.emails-widget-scope .btn-close-modal { background: none; border: none; font-size: 1.5rem; @@ -656,12 +652,12 @@ height: auto; } -.btn-close-modal:hover { +.emails-widget-scope .btn-close-modal:hover { color: #212529; } /* Body Layout */ -.schedule-modal-body { +.emails-widget-scope .schedule-modal-body { display: flex; gap: 2rem; flex: 1; @@ -669,19 +665,19 @@ } /* Calendar Section */ -.schedule-calendar-section { +.emails-widget-scope .schedule-calendar-section { flex: 1; min-width: 300px; } -.calendar-nav { +.emails-widget-scope .calendar-nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } -.calendar-month-year { +.emails-widget-scope .calendar-month-year { font-size: 1.1rem; font-weight: 600; color: #212529; @@ -689,7 +685,7 @@ text-align: center; } -.calendar-nav button { +.emails-widget-scope .calendar-nav button { width: 32px; height: 32px; padding: 0; @@ -697,13 +693,13 @@ } /* Calendar Grid */ -.calendar-grid { +.emails-widget-scope .calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; } -.calendar-day-header { +.emails-widget-scope .calendar-day-header { text-align: center; font-weight: 600; font-size: 0.85rem; @@ -714,7 +710,7 @@ grid-column: span 1; } -.calendar-day { +.emails-widget-scope .calendar-day { text-align: center; padding: 8px; cursor: pointer; @@ -724,37 +720,37 @@ border: 2px solid transparent; } -.calendar-day.past { +.emails-widget-scope .calendar-day.past { color: #adb5bd; cursor: not-allowed; background: transparent; } -.calendar-day.today { +.emails-widget-scope .calendar-day.today { background: #0d6efd; color: #fff; border-radius: 50%; font-weight: 600; } -.calendar-day.selected { +.emails-widget-scope .calendar-day.selected { border-color: #0d6efd; font-weight: 600; background: #e7f1ff; } -.calendar-day:not(.past):hover { +.emails-widget-scope .calendar-day:not(.past):hover { background: #f8f9fa; border-color: #0d6efd; } -.calendar-day.today.selected { +.emails-widget-scope .calendar-day.today.selected { border-color: #fff; background: #0d6efd; } /* Inputs Section */ -.schedule-inputs-section { +.emails-widget-scope .schedule-inputs-section { flex: 1; min-width: 240px; display: flex; @@ -762,108 +758,108 @@ gap: 1.5rem; } -#schedule-date-input { +.emails-widget-scope #schedule-date-input { width: 100%; box-sizing: border-box; } -.schedule-input-group { +.emails-widget-scope .schedule-input-group { display: flex; flex-direction: column; } -.schedule-input-group label { +.emails-widget-scope .schedule-input-group label { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.5rem; color: #495057; } -#schedule-date-input, -#schedule-time-select { +.emails-widget-scope #schedule-date-input, +.emails-widget-scope #schedule-time-select { padding: 0.5rem 0.75rem; font-size: 0.95rem; border: 1px solid #ced4da; border-radius: 0.25rem; } -#schedule-date-input:focus, -#schedule-time-select:focus { +.emails-widget-scope #schedule-date-input:focus, +.emails-widget-scope #schedule-time-select:focus { border-color: #0d6efd; box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); outline: none; } -.input-group-text { +.emails-widget-scope .input-group-text { background: #f8f9fa; border-color: #ced4da; color: #495057; } /* Footer */ -.schedule-modal-footer { +.emails-widget-scope .schedule-modal-footer { margin-top: auto; padding-top: 16px; border-top: 1px solid #dee2e6; } -.schedule-modal-footer button { +.emails-widget-scope .schedule-modal-footer button { min-width: 100px; } -#schedule-modal-validation { +.emails-widget-scope #schedule-modal-validation { font-size: 0.85rem; } /* Responsive */ @media (max-width: 768px) { - #schedule-send-modal { + .emails-widget-scope #schedule-send-modal { width: 95vw; max-height: 95vh; padding: 16px; } - .schedule-modal-body { + .emails-widget-scope .schedule-modal-body { flex-direction: column; gap: 1rem; min-height: auto; } - .schedule-calendar-section, - .schedule-inputs-section { + .emails-widget-scope .schedule-calendar-section, + .emails-widget-scope .schedule-inputs-section { min-width: auto; width: 100%; } } -#btn-picker-ok { +.emails-widget-scope #btn-picker-ok { display: none; } -#send-on-display { +.emails-widget-scope #send-on-display { font-size: 0.9em; } -#btn-clear-schedule { +.emails-widget-scope #btn-clear-schedule { display: none; } -#delay-datetime-validation { +.emails-widget-scope #delay-datetime-validation { display: none; font-size: 0.85em; } -.send-date-hint { +.emails-widget-scope .send-date-hint { font-size: 0.85em; } -input#EmailFrom.input-validation-error { - margin-bottom: -20px !important; +.emails-widget-scope input#EmailFrom.input-validation-error { + margin-bottom: 0 !important; } -.mt-14 { +.emails-widget-scope .mt-14 { margin-top: 14px !important; } @@ -878,12 +874,12 @@ input#EmailFrom.input-validation-error { ============================================ */ /* Hide TinyMCE toolbar completely */ -.tox-tinymce:has(#EmailBody) .tox-toolbar__primary { +.emails-widget-scope .tox-tinymce:has(#EmailBody) .tox-toolbar__primary { display: none !important; } /* Container for template label and menu buttons */ -.tinymce-template-label-top-right { +.emails-widget-scope .tinymce-template-label-top-right { position: static !important; font-size: 13px; color: #444; @@ -898,7 +894,7 @@ input#EmailFrom.input-validation-error { /* Template label text */ -.template-label-text { +.emails-widget-scope .template-label-text { display: flex !important; align-items: center !important; justify-content: center !important; @@ -914,20 +910,20 @@ input#EmailFrom.input-validation-error { transition: all 0.15s ease-in-out; } -.template-label-text:hover { +.emails-widget-scope .template-label-text:hover { background-color: #6c757d; color: #fff !important; } -.template-label-text.no-permission { +.emails-widget-scope .template-label-text.no-permission { cursor: default; background: #f9f9f9; } /* TinyMCE-style toolbar group for templates button */ -.templates-toolbar-group { +.emails-widget-scope .templates-toolbar-group { display: flex; align-items: center; gap: 0; @@ -936,7 +932,7 @@ input#EmailFrom.input-validation-error { } /* TinyMCE-style menu button */ -.tinymce-menu-button { +.emails-widget-scope .tinymce-menu-button { display: inline-flex; align-items: center; padding: 0; @@ -953,28 +949,28 @@ input#EmailFrom.input-validation-error { transition: background-color 0.1s ease-in-out; } -.tinymce-menu-button:hover { +.emails-widget-scope .tinymce-menu-button:hover { background: #e0e0e0; } -.tinymce-menu-button:active { +.emails-widget-scope .tinymce-menu-button:active { background: #d0d0d0; } -.tinymce-menu-button.tox-tbtn--select { +.emails-widget-scope .tinymce-menu-button.tox-tbtn--select { justify-content: space-between; padding: 0 8px; gap: 4px; } -.tox-tbtn__select-label { +.emails-widget-scope .tox-tbtn__select-label { display: inline-block; font-weight: 500; color: #333; font-size: 13px; } -.tox-tbtn__select-chevron { +.emails-widget-scope .tox-tbtn__select-chevron { display: flex; align-items: center; justify-content: center; @@ -983,14 +979,14 @@ input#EmailFrom.input-validation-error { flex-shrink: 0; } -.tox-tbtn__select-chevron svg { +.emails-widget-scope .tox-tbtn__select-chevron svg { width: 10px; height: 10px; display: block; } /* Dropdown menu styling */ -.templates-dropdown-menu { +.emails-widget-scope .templates-dropdown-menu { position: fixed !important; background: #fff; border: 1px solid #ccc; @@ -1004,14 +1000,14 @@ input#EmailFrom.input-validation-error { } /* Custom dropdown menu styling (not Bootstrap) */ -.custom-dropdown-menu { +.emails-widget-scope .custom-dropdown-menu { list-style: none; padding: 0 !important; margin: 0 !important; display: block !important; } -.custom-dropdown-item { +.emails-widget-scope .custom-dropdown-item { padding: 8px 12px; cursor: pointer; border-bottom: 1px solid #eee; @@ -1022,23 +1018,23 @@ input#EmailFrom.input-validation-error { display: block !important; } -.custom-dropdown-item:hover { +.emails-widget-scope .custom-dropdown-item:hover { background: #f5f5f5; } -.custom-dropdown-item:last-child { +.emails-widget-scope .custom-dropdown-item:last-child { border-bottom: none !important; } /* Position Templates button in top right of toolbar */ -.tox:has(#EmailBody) .templates-button-container { +.emails-widget-scope .tox:has(#EmailBody) .templates-button-container { position: absolute !important; top: 5px !important; right: 10px !important; z-index: 10 !important; } -.tox *:not(svg):not(rect):not(.tox-tooltip):not(.tox-tooltip *):not(.tox-dialog):not(.tox-dialog *):not(.tox-menu):not(.tox-menu *):not(.tox-collection):not(.tox-collection *) { +.emails-widget-scope .tox *:not(svg):not(rect):not(.tox-tooltip):not(.tox-tooltip *):not(.tox-dialog):not(.tox-dialog *):not(.tox-menu):not(.tox-menu *):not(.tox-collection):not(.tox-collection *) { background-color: initial; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; font-size: 14px !important; @@ -1048,7 +1044,7 @@ input#EmailFrom.input-validation-error { } /* Style the Templates select element */ -.templates-select { +.emails-widget-scope .templates-select { background: #f7f7f7 !important; padding: 4px 8px !important; border: 1px solid #ccc !important; @@ -1063,8 +1059,8 @@ input#EmailFrom.input-validation-error { -.templates-select:active, -.templates-select:focus { +.emails-widget-scope .templates-select:active, +.emails-widget-scope .templates-select:focus { outline: none !important; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index a361aef984..7df1f8f1c5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -1,7 +1,15 @@ -$(document).ready(function () { +// Wrapped in a named, re-callable function (rather than a bare $(document).ready IIFE) so this widget can be +// mounted more than once per page — e.g. re-initialized against a freshly swapped-in DOM each time a different +// application's draft is selected in the bulk-send modal's edit panel. Every handler bound to a *persistent* +// target (document, a page-level scroll container, PubSub's own global subscriber registry) is explicitly +// unbound/unsubscribed before re-binding below, so repeated calls don't accumulate duplicate listeners. Handlers +// bound to elements that are part of this widget's own markup (e.g. #btn-save-top, the attachments table) don't +// need that treatment: each call captures fresh references to the newly-rendered DOM, and the previous DOM (with +// whatever was bound to it) is simply discarded along with the old closure. +function initializeEmailsWidget() { const BC_PERMANENT_DST_ZONE = 'UTC-7'; // Close dropdown menus when clicking outside - $(document).on('click', function (e) { + $(document).off('click.emailWidgetDropdown').on('click.emailWidgetDropdown', function (e) { if (!$(e.target).closest('.tinymce-menu-button, .custom-dropdown-menu').length) { $('.custom-dropdown-menu').remove(); } @@ -104,6 +112,17 @@ $('.btn-send-menu').off('click'); $('.btn-schedule-send-menu').off('click'); + // #EmailForm contains a type="submit" button (#btn-send-top). Clicking it is safely intercepted by + // handleSendEmail's preventDefault(), but pressing Enter in a single-line field (To/From/CC/BCC/Subject) + // triggers the browser's *implicit* form submission directly, bypassing that click handler entirely. + // Every real Save/Send path here is already JS/AJAX-driven, so a native submit of this form is never + // correct — block it outright. This matters more now that this form can end up nested inside another + //
(the bulk-send modal's own form) when this widget is reused there; nested forms are invalid + // HTML, and an unhandled native submit in that context would be especially disruptive. + UIElements.emailForm.off('submit.emailWidgetGuard').on('submit.emailWidgetGuard', function (e) { + e.preventDefault(); + }); + // Bind button handlers UIElements.btnNewEmail.on('click', function (e) { e.preventDefault(); @@ -186,7 +205,7 @@ bindDelayModeEvents(); - $('.details-scrollable').on('scroll.emailWidget', function () { + $('.details-scrollable').off('scroll.emailWidget').on('scroll.emailWidget', function () { $('.tox-toolbar__overflow').hide(); }); @@ -1482,7 +1501,10 @@ hideConfirmation(); handleCloseEmail(); abp.notify.success('Your email is being sent'); - PubSub.publish('refresh_application_emails'); + // Pass along which application this save/send actually belonged to — a listener elsewhere on the + // page (e.g. a multi-application context switching selection) can't otherwise tell which row this + // completion is for, since UIElements.applicationId isn't visible outside this closure. + PubSub.publish('refresh_application_emails', { applicationId: UIElements.applicationId }); }).fail(function () { hideConfirmation(); abp.notify.error('An error ocurred your email could not be sent.'); @@ -1553,7 +1575,8 @@ isNewEmailDraft = false; newDraftId = null; handleCloseEmail(); abp.notify.success('Your email has been saved.'); - PubSub.publish('refresh_application_emails'); + // See the matching comment in performSendEmail's success handler above. + PubSub.publish('refresh_application_emails', { applicationId: UIElements.applicationId }); }).fail(function () { UIElements.btnSave.prop('disabled', false); abp.notify.error('An error ocurred your email could not be saved.'); @@ -2067,6 +2090,15 @@ }); } + // This widget is the sole subscriber to each of these four topics anywhere in the app (confirmed by + // project-wide search) — unsubscribe before re-subscribing so repeated initializeEmailsWidget() calls + // don't leave stale handlers (bound to a previous, now-discarded DOM/closure) stacking up alongside the + // current one. + PubSub.unsubscribe('email_selected'); + PubSub.unsubscribe('applicant_info_updated'); + PubSub.unsubscribe('draft_email_deleted'); + PubSub.unsubscribe('reload_email_attachments_table'); + PubSub.subscribe('email_selected', (msg, data) => { console.log("EMAIL SELECTED EVENT FIRED", data); @@ -2351,10 +2383,10 @@ 'maximum allowed ' + totalMaxFileSize + ' MB. Please remove one or more attachments before sending.' ); $('#email-attachment-size-error').show(); - $('#btn-send').prop('disabled', true); + $('#btn-send-top').prop('disabled', true); } else { $('#email-attachment-size-error').hide(); - $('#btn-send').prop('disabled', false); + $('#btn-send-top').prop('disabled', false); } } @@ -2481,8 +2513,26 @@ } }); } +} + +$(document).ready(function () { + // This script is now also bundled on pages (e.g. GrantApplications' list page) that mount this widget's + // markup dynamically, later, only for users with the right permission/feature — never as part of the + // page's own initial HTML. Only auto-run setup if the widget's markup (and the ambient hidden fields it + // reads, like #DetailsViewApplicationId) is actually already present, as it always is on the page that + // server-renders this widget directly (GrantApplications/Details). Callers that mount this widget + // dynamically elsewhere call window.EmailsWidget.reinitialize() themselves once their markup exists. + if ($('#EmailForm').length) { + initializeEmailsWidget(); + } }); +// Exposed so callers that dynamically (re)mount this widget's markup elsewhere on the page — outside the +// normal single server-rendered-once-per-page usage on GrantApplications/Details — can re-run its setup +// against the freshly-inserted DOM. See SendEmailNotificationModal.js for the current caller. +window.EmailsWidget = window.EmailsWidget || {}; +window.EmailsWidget.reinitialize = initializeEmailsWidget; + /** * Returns TinyMCE editor toolbar options diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/EmailsWidgetController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/EmailsWidgetController.cs index 3053ce8e5a..a6050b3d1c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/EmailsWidgetController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/EmailsWidgetController.cs @@ -14,14 +14,14 @@ public class EmailsWidgetController : AbpController [HttpGet] [Route("RefreshEmails")] - public IActionResult Emails(Guid ownerId, Guid currentUserId) - { + public IActionResult Emails(Guid applicationId, Guid currentUserId) + { if (!ModelState.IsValid) { logger.LogWarning("Invalid model state for EmailsWidgetController: RefreshEmails"); return ViewComponent("EmailsWidget"); } - return ViewComponent("EmailsWidget", new { ownerId, currentUserId }); + return ViewComponent("EmailsWidget", new { applicationId, currentUserId }); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json index 5c43780a38..c4fedb9f3b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/package.json @@ -5,6 +5,7 @@ "dependencies": { "@abp/aspnetcore.mvc.ui.theme.basic": "~10.5.0", "@abp/font-awesome": "~10.5.0", + "@abp/signalr": "~10.5.0", "datatables.net-bs5": "~2.3.8", "datatables.net-buttons-bs5": "~3.2.6", "datatables.net-colreorder": "~2.1.2", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock index d84cb874a7..11fdcdb273 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/yarn.lock @@ -162,6 +162,14 @@ "@abp/core" "~10.5.0" select2 "^4.0.13" +"@abp/signalr@~10.5.0": + version "10.5.0" + resolved "https://registry.yarnpkg.com/@abp/signalr/-/signalr-10.5.0.tgz#009eff8e23f5eb2ce441d09950aa0e0f051c56da" + integrity sha512-/t8QdgPONmeVpE3YCKrVT50jvach5hXZ4yfR0lC96jsjCsy58d61DLa+J4r7c2KcHEL90iDFU/aTj9peK8FyRg== + dependencies: + "@abp/core" "~10.5.0" + "@microsoft/signalr" "~9.0.6" + "@abp/sweetalert2@~10.5.0": version "10.5.0" resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.5.0.tgz#e5a2dc2d2223ca842c3e7f9c5fb5b3c4d61229f2" @@ -226,6 +234,17 @@ resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz#9d7d7df8f850637e001d24e6cd901422b4b09099" integrity sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg== +"@microsoft/signalr@~9.0.6": + version "9.0.19" + resolved "https://registry.yarnpkg.com/@microsoft/signalr/-/signalr-9.0.19.tgz#810ec19814342c425b6fa2386d92fe4160a5b302" + integrity sha512-hKDxt4nRXvkhhy+Etlhbg0UDQdhk8PsZKlQTnHyCn+bb+Q6gM8RCZLI43WsLH1IQCa7l/G8tX09em1F1LH/C1w== + dependencies: + abort-controller "^3.0.0" + eventsource "^2.0.2" + fetch-cookie "^2.0.3" + node-fetch "^2.6.7" + ws "^7.5.10" + "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.0": version "2.11.8" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" @@ -253,6 +272,13 @@ resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + abortcontroller-polyfill@^1.7.5: version "1.7.8" resolved "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.8.tgz" @@ -541,6 +567,11 @@ echarts@~6.1.0: tslib "2.3.0" zrender "6.1.0" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.7: version "4.0.7" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" @@ -551,6 +582,11 @@ eventemitter3@^5.0.1: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== +eventsource@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" + integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== + fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -566,6 +602,14 @@ fast-json-patch@^3.1.1: resolved "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz" integrity sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ== +fetch-cookie@^2.0.3: + version "2.2.0" + resolved "https://registry.yarnpkg.com/fetch-cookie/-/fetch-cookie-2.2.0.tgz#01086b6b5b1c3e08f15ffd8647b02ca100377365" + integrity sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ== + dependencies: + set-cookie-parser "^2.4.8" + tough-cookie "^4.0.0" + fetch-ponyfill@^7.1.0: version "7.1.0" resolved "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz" @@ -809,6 +853,13 @@ neo-async@^2.6.2: resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-fetch@~2.6.1: version "2.6.13" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz" @@ -831,11 +882,28 @@ process-nextick-args@~2.0.0: resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +psl@^1.1.33: + version "1.15.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.15.0.tgz#bdace31896f1d97cec6a79e8224898ce93d974c6" + integrity sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w== + dependencies: + punycode "^2.3.1" + pubsub-js@~1.9.5: version "1.9.5" resolved "https://registry.npmjs.org/pubsub-js/-/pubsub-js-1.9.5.tgz" integrity sha512-5MZ0I9i5JWVO7SizvOviKvZU2qaBbl2KQX150FAA+fJBwYpwOUId7aNygURWSdPzlsA/xZ/InUKXqBbzM0czTA== +punycode@^2.1.1, punycode@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + quill-delta@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz" @@ -875,6 +943,11 @@ redux@^4.2.0: dependencies: "@babel/runtime" "^7.9.2" +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz" @@ -897,6 +970,11 @@ select2@^4.0.13, select2@~4.1.0: resolved "https://registry.npmjs.org/select2/-/select2-4.1.0.tgz" integrity sha512-i9KalWOP4/LRRGc8+rj2krNm0ZqP14cV+j1TRCEBSsOhCPkKH8rYZ2MCRXcgvqIqN+llqGci0hj9aVkCMvL0+g== +set-cookie-parser@^2.4.8: + version "2.7.2" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz#ccd08673a9ae5d2e44ea2a2de25089e67c7edf68" + integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" @@ -963,6 +1041,16 @@ tippy.js@^6.3.7: dependencies: "@popperjs/core" "^2.9.0" +tough-cookie@^4.0.0: + version "4.1.4" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36" + integrity sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz" @@ -988,6 +1076,19 @@ uglify-js@^3.1.4: resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz" integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" @@ -1023,6 +1124,11 @@ wordwrap@^1.0.0: resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== +ws@^7.5.10: + version "7.5.13" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.13.tgz#12aa507eaca76c295c278b1aebf4698ab2c1845f" + integrity sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA== + zrender@6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/zrender/-/zrender-6.1.0.tgz#c3ef06e6426d5c28865b7183035680d685e00136" diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs index effd117a15..281e5dba69 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/Applicants/SubmissionInfoDataProviderTests.cs @@ -117,13 +117,24 @@ private static ApplicationStatus CreateStatus(Guid id, string externalStatus, st return entity; } - private static ApplicationForm CreateForm(Guid id, string formName) + private static ApplicationForm CreateForm(Guid id, string formName, Action? configure = null) { var entity = new ApplicationForm { ApplicationFormName = formName }; EntityHelper.TrySetId(entity, () => id); + configure?.Invoke(entity); return entity; } + private static ExternalLink CreateExternalLink( + ExternalLinkType type, bool published, int order = -1, string uri = "https://example.com") + => new() + { + Uri = uri, + ExternalLinkType = type, + Published = published, + Order = order + }; + [Fact] public async Task GetDataAsync_ShouldChangeTenant() { @@ -504,5 +515,232 @@ public async Task GetDataAsync_ShouldFallBackToCreationTimeWhenSubmissionIsNullO var dto = result.ShouldBeOfType(); dto.Submissions[0].SubmissionTime.ShouldBe(creationTime); } + + [Fact] + public async Task GetDataAsync_ShouldReturnRenewalLink_WhenEligibleAndPublished() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId, a => + { + a.ApplicationFormId = formId; + a.EligibleForRenewal = true; + })], + [CreateForm(formId, "Form", f => f.ExternalLinksConfig = new ExternalLinksConfig + { + Links = [CreateExternalLink(ExternalLinkType.Renewal, published: true, uri: "https://renewal.example.com")] + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].RenewalLink.ShouldNotBeNull(); + dto.Submissions[0].RenewalLink!.Uri.ShouldBe("https://renewal.example.com"); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnRenewalLink_WhenNotEligibleForRenewal() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId, a => + { + a.ApplicationFormId = formId; + a.EligibleForRenewal = false; + })], + [CreateForm(formId, "Form", f => f.ExternalLinksConfig = new ExternalLinksConfig + { + Links = [CreateExternalLink(ExternalLinkType.Renewal, published: true)] + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].RenewalLink.ShouldBeNull(); + } + + [Fact] + public async Task GetDataAsync_ShouldNotReturnRenewalLink_WhenUnpublished() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId, a => + { + a.ApplicationFormId = formId; + a.EligibleForRenewal = true; + })], + [CreateForm(formId, "Form", f => f.ExternalLinksConfig = new ExternalLinksConfig + { + Links = [CreateExternalLink(ExternalLinkType.Renewal, published: false)] + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].RenewalLink.ShouldBeNull(); + } + + [Fact] + public async Task GetDataAsync_ShouldExcludeUnpublishedRelatedLinks() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId, a => a.ApplicationFormId = formId)], + [CreateForm(formId, "Form", f => f.ExternalLinksConfig = new ExternalLinksConfig + { + Links = + [ + CreateExternalLink(ExternalLinkType.Related, published: true, order: 1, uri: "https://published.example.com"), + CreateExternalLink(ExternalLinkType.Related, published: false, order: 2, uri: "https://unpublished.example.com") + ] + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].RelatedLinks.Count.ShouldBe(1); + dto.Submissions[0].RelatedLinks[0].Uri.ShouldBe("https://published.example.com"); + } + + [Fact] + public async Task GetDataAsync_ShouldReturnRelatedLinksInConfiguredOrder() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId, a => a.ApplicationFormId = formId)], + [CreateForm(formId, "Form", f => f.ExternalLinksConfig = new ExternalLinksConfig + { + Links = + [ + CreateExternalLink(ExternalLinkType.Related, published: true, order: 2, uri: "https://second.example.com"), + CreateExternalLink(ExternalLinkType.Related, published: true, order: 0, uri: "https://first.example.com"), + CreateExternalLink(ExternalLinkType.Related, published: true, order: 1, uri: "https://middle.example.com") + ] + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + var relatedLinks = dto.Submissions[0].RelatedLinks; + relatedLinks.Count.ShouldBe(3); + relatedLinks[0].Uri.ShouldBe("https://first.example.com"); + relatedLinks[1].Uri.ShouldBe("https://middle.example.com"); + relatedLinks[2].Uri.ShouldBe("https://second.example.com"); + } + + [Fact] + public async Task GetDataAsync_ShouldOrderUnorderedRelatedLinksLast() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId, a => a.ApplicationFormId = formId)], + [CreateForm(formId, "Form", f => f.ExternalLinksConfig = new ExternalLinksConfig + { + Links = + [ + CreateExternalLink(ExternalLinkType.Related, published: true, order: -1, uri: "https://unordered.example.com"), + CreateExternalLink(ExternalLinkType.Related, published: true, order: 0, uri: "https://ordered.example.com") + ] + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + var relatedLinks = dto.Submissions[0].RelatedLinks; + relatedLinks.Count.ShouldBe(2); + relatedLinks[0].Uri.ShouldBe("https://ordered.example.com"); + relatedLinks[1].Uri.ShouldBe("https://unordered.example.com"); + } + + [Fact] + public async Task GetDataAsync_ShouldNotMixRenewalAndRelatedLinks() + { + // Arrange + var request = CreateRequest(); + var applicationId = Guid.NewGuid(); + var formId = Guid.NewGuid(); + var statusId = Guid.NewGuid(); + + SetupQueryables( + [CreateSubmission(applicationId, "TESTUSER")], + [CreateApplication(applicationId, statusId, a => + { + a.ApplicationFormId = formId; + a.EligibleForRenewal = true; + })], + [CreateForm(formId, "Form", f => f.ExternalLinksConfig = new ExternalLinksConfig + { + Links = + [ + CreateExternalLink(ExternalLinkType.Renewal, published: true, uri: "https://renewal.example.com"), + CreateExternalLink(ExternalLinkType.Related, published: true, uri: "https://related.example.com") + ] + })], + [CreateStatus(statusId, "Submitted")]); + + // Act + var result = await _provider.GetDataAsync(request); + + // Assert + var dto = result.ShouldBeOfType(); + dto.Submissions[0].RenewalLink.ShouldNotBeNull(); + dto.Submissions[0].RenewalLink!.Uri.ShouldBe("https://renewal.example.com"); + dto.Submissions[0].RelatedLinks.Count.ShouldBe(1); + dto.Submissions[0].RelatedLinks[0].Uri.ShouldBe("https://related.example.com"); + } } } diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceMappingReviewTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceMappingReviewTests.cs index 3587b58ea6..c036525370 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceMappingReviewTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceMappingReviewTests.cs @@ -8,6 +8,7 @@ using Shouldly; using Unity.AI.Generation; using Unity.AI.Operations; +using Unity.AI.Settings; using Unity.Flex.Domain.Worksheets; using Unity.Flex.Domain.WorksheetLinks; using Unity.Flex.Worksheets; @@ -283,6 +284,9 @@ private ApplicationFormVersionAppService CreateService( IWorksheetRepository? worksheetRepository = null, IWorksheetLinkRepository? worksheetLinkRepository = null) { + var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync(Arg.Any()).Returns(true); + var localizer = Substitute.For>(); var service = new ApplicationFormVersionAppService( repository, Substitute.For(), @@ -291,8 +295,9 @@ private ApplicationFormVersionAppService CreateService( Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For(), - Substitute.For>(), + featureChecker, + new AIFeatureGuard(featureChecker, localizer), + localizer, generationService ?? Substitute.For(), worksheetRepository ?? Substitute.For(), Substitute.For>(), diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs index 120c098fff..2289018d11 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ApplicationFormVersionAppServiceTests.cs @@ -9,6 +9,7 @@ using Unity.AI; using Unity.AI.Features; using Unity.AI.Localization; +using Unity.AI.Settings; using Microsoft.Extensions.Localization; using Unity.AI.Generation; using Unity.AI.Operations; @@ -427,6 +428,8 @@ private static ApplicationFormVersionAppService CreateService( Unity.Flex.Domain.ScoresheetInstances.IScoresheetInstanceRepository? scoresheetInstanceRepository = null) { var featureChecker = Substitute.For(); + featureChecker.IsEnabledAsync(Arg.Any()).Returns(true); + var localizer = Substitute.For>(); var service = new ApplicationFormVersionAppService( repository, Substitute.For(), @@ -436,7 +439,8 @@ private static ApplicationFormVersionAppService CreateService( Substitute.For(), Substitute.For(), featureChecker, - Substitute.For>(), + new AIFeatureGuard(featureChecker, localizer), + localizer, aiGenerationAppService, worksheetRepository ?? Substitute.For(), customFieldRepository ?? Substitute.For>(), diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ExternalLinksConfigValidationTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ExternalLinksConfigValidationTests.cs new file mode 100644 index 0000000000..906e533265 --- /dev/null +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Application.Tests/ApplicationForms/ExternalLinksConfigValidationTests.cs @@ -0,0 +1,157 @@ +using Shouldly; +using System; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.Applications; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Validation; +using Xunit; +using Xunit.Abstractions; + +namespace Unity.GrantManager.ApplicationForms; + +public class ExternalLinksConfigValidationTests : GrantManagerApplicationTestBase +{ + private readonly IApplicationFormAppService _applicationFormAppService; + private readonly IRepository _applicationFormRepository; + + public ExternalLinksConfigValidationTests(ITestOutputHelper outputHelper) : base(outputHelper) + { + _applicationFormAppService = GetRequiredService(); + _applicationFormRepository = GetRequiredService>(); + } + + [Fact] + public async Task PatchExternalLinksConfigAsync_ShouldSaveValidRenewalLinkAndRelatedLinks() + { + await _applicationFormAppService.PatchExternalLinksConfigAsync( + GrantManagerTestData.ApplicationForm1_Id, + new ExternalLinksConfigDto + { + RenewalLink = new ExternalLinkConfigDto + { + Uri = "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form", + Title = "Renew Now", + Description = "Please renew before the deadline.", + Published = true, + ExternalLinkType = ExternalLinkType.Renewal + }, + RelatedLinks = + [ + new ExternalLinkConfigDto + { + Uri = "https://chefs-test.apps.silver.devops.gov.bc.ca/app/related-1", + Title = "Related One", + Description = "First related link.", + Published = true, + ExternalLinkType = ExternalLinkType.Related + }, + new ExternalLinkConfigDto + { + Uri = "https://chefs-test.apps.silver.devops.gov.bc.ca/app/related-2", + Title = "Related Two", + Description = "Second related link.", + Published = false, + ExternalLinkType = ExternalLinkType.Related + } + ], + ApplicantMessage = "Renewal message for applicants." + }); + + var form = await _applicationFormRepository.GetAsync(GrantManagerTestData.ApplicationForm1_Id); + + form.ExternalLinksConfig.Links.Count.ShouldBe(3); + form.ExternalLinksConfig.ApplicantMessage.ShouldBe("Renewal message for applicants."); + var renewalLink = form.ExternalLinksConfig.Links.Single(l => l.ExternalLinkType == ExternalLinkType.Renewal); + renewalLink.Uri.ShouldBe("https://chefs-test.apps.silver.devops.gov.bc.ca/app/form"); + renewalLink.Title.ShouldBe("Renew Now"); + renewalLink.Description.ShouldBe("Please renew before the deadline."); + renewalLink.Published.ShouldBeTrue(); + + var relatedLinks = form.ExternalLinksConfig.Links + .Where(l => l.ExternalLinkType == ExternalLinkType.Related) + .OrderBy(l => l.Order) + .ToList(); + relatedLinks.Count.ShouldBe(2); + relatedLinks[0].Title.ShouldBe("Related One"); + relatedLinks[0].Order.ShouldBe(-1); + relatedLinks[1].Title.ShouldBe("Related Two"); + relatedLinks[1].Order.ShouldBe(-1); + } + + [Fact] + public async Task PatchExternalLinksConfigAsync_ShouldReplaceRelatedLinks_OnSubsequentSave() + { + await _applicationFormAppService.PatchExternalLinksConfigAsync( + GrantManagerTestData.ApplicationForm1_Id, + new ExternalLinksConfigDto + { + RelatedLinks = + [ + new ExternalLinkConfigDto { Uri = "https://chefs-test.apps.silver.devops.gov.bc.ca/app/first" } + ] + }); + + await _applicationFormAppService.PatchExternalLinksConfigAsync( + GrantManagerTestData.ApplicationForm1_Id, + new ExternalLinksConfigDto + { + RelatedLinks = + [ + new ExternalLinkConfigDto { Uri = "https://chefs-test.apps.silver.devops.gov.bc.ca/app/second" }, + new ExternalLinkConfigDto { Uri = "https://chefs-test.apps.silver.devops.gov.bc.ca/app/third" } + ] + }); + + var form = await _applicationFormRepository.GetAsync(GrantManagerTestData.ApplicationForm1_Id); + var relatedLinks = form.ExternalLinksConfig.Links.Where(l => l.ExternalLinkType == ExternalLinkType.Related).ToList(); + + relatedLinks.Count.ShouldBe(2); + relatedLinks.ShouldNotContain(l => l.Uri.EndsWith("first", StringComparison.Ordinal)); + } + + [Fact] + public async Task PatchExternalLinksConfigAsync_ShouldReject_WhenRenewalLinkVisibleWithoutUri() + { + await Should.ThrowAsync( + _applicationFormAppService.PatchExternalLinksConfigAsync( + GrantManagerTestData.ApplicationForm1_Id, + new ExternalLinksConfigDto + { + RenewalLink = new ExternalLinkConfigDto + { + Uri = string.Empty, + Published = true + } + })); + } + + [Fact] + public async Task PatchExternalLinksConfigAsync_ShouldReject_WhenExceedingMaxRelatedLinks() + { + var relatedLinks = Enumerable.Range(1, ApplicationForm.MaxRelatedExternalLinks + 1) + .Select(i => new ExternalLinkConfigDto { Uri = $"https://chefs-test.apps.silver.devops.gov.bc.ca/app/link-{i}" }) + .ToList(); + + await Should.ThrowAsync( + _applicationFormAppService.PatchExternalLinksConfigAsync( + GrantManagerTestData.ApplicationForm1_Id, + new ExternalLinksConfigDto { RelatedLinks = relatedLinks })); + } + + [Fact] + public async Task PatchExternalLinksConfigAsync_ShouldReject_WhenUriIsScriptScheme() + { + var dto = new ExternalLinksConfigDto + { + RenewalLink = new ExternalLinkConfigDto + { + Uri = "javascript:alert(1)" + } + }; + + await Should.ThrowAsync( + _applicationFormAppService.PatchExternalLinksConfigAsync(GrantManagerTestData.ApplicationForm1_Id, dto)); + } +} diff --git a/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/ApplicationForms/ApplicationFormTests.cs b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/ApplicationForms/ApplicationFormTests.cs index 9675be6a74..228c0dd2b0 100644 --- a/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/ApplicationForms/ApplicationFormTests.cs +++ b/applications/Unity.GrantManager/test/Unity.GrantManager.Domain.Tests/ApplicationForms/ApplicationFormTests.cs @@ -1,6 +1,10 @@ -using Unity.GrantManager.Applications; -using Xunit; +using Shouldly; +using System.Collections.Generic; +using Unity.GrantManager.ApplicantProfile; +using Unity.GrantManager.Applications; using Unity.GrantManager.GrantApplications; +using Volo.Abp; +using Xunit; namespace Unity.GrantManager.ApplicationForms { @@ -33,6 +37,72 @@ public void GetAvailableElectoralDistrictAddressTypesReturnsExpected() x => x.AddressType == AddressType.MailingAddress ); } + + [Fact] + public void SetExternalLinks_ShouldThrow_WhenRenewalLinkPublishedWithoutUri() + { + var form = new ApplicationForm(); + + var exception = Should.Throw(() => + form.SetExternalLinks( + new ExternalLink { Uri = string.Empty, Published = true }, + [])); + + exception.Code.ShouldBe(GrantManagerDomainErrorCodes.RenewalLinkRequiredForVisibility); + } + + [Fact] + public void SetExternalLinks_ShouldThrow_WhenRelatedLinkPublishedWithoutUri() + { + var form = new ApplicationForm(); + + var exception = Should.Throw(() => + form.SetExternalLinks( + null, + [new ExternalLink { Uri = string.Empty, Published = true }])); + + exception.Code.ShouldBe(GrantManagerDomainErrorCodes.RelatedLinkInvalidUri); + } + + [Fact] + public void SetExternalLinks_ShouldThrow_WhenExceedingMaxRelatedLinks() + { + var form = new ApplicationForm(); + var relatedLinks = new List(); + for (var i = 0; i <= ApplicationForm.MaxRelatedExternalLinks; i++) + { + relatedLinks.Add(new ExternalLink { Uri = $"https://example.com/{i}" }); + } + + var exception = Should.Throw(() => + form.SetExternalLinks(null, relatedLinks)); + + exception.Code.ShouldBe(GrantManagerDomainErrorCodes.TooManyRelatedLinks); + } + + [Fact] + public void SetExternalLinks_ShouldAssignOrderAndTypes_WhenValid() + { + var form = new ApplicationForm(); + + form.SetExternalLinks( + new ExternalLink { Uri = "https://example.com/renew", Published = true }, + [ + new ExternalLink { Uri = "https://example.com/one", Order = 2 }, + new ExternalLink { Uri = "https://example.com/two", Order = 1} + ], + "Please renew soon."); + + var links = form.ExternalLinksConfig.Links; + links.Count.ShouldBe(3); + links[0].ExternalLinkType.ShouldBe(ExternalLinkType.Renewal); + links[0].Order.ShouldBe(-1); + links[1].ExternalLinkType.ShouldBe(ExternalLinkType.Related); + links[1].Order.ShouldBe(2); + links[2].ExternalLinkType.ShouldBe(ExternalLinkType.Related); + links[2].Order.ShouldBe(1); + form.ExternalLinksConfig.ApplicantMessage.ShouldBe("Please renew soon."); + } } }