diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..7380956107 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,44 @@ +# Unity Portal – Copilot Instructions (Repository Root) + +> This file is used by **Copilot on GitHub.com** (PR reviews, web chat). For local IDE instructions, see `applications/Unity.GrantManager/.github/copilot-instructions.md`. + +## Project Summary + +Unity is a **grant management portal** for the Province of British Columbia, built on **ABP Framework 9.1.3** with **.NET 9.0**, targeting **PostgreSQL 17**. The primary application code lives in `applications/Unity.GrantManager/`. + +**Key stack:** .NET 9 · ABP 9.1.3 · EF Core 9.0 · PostgreSQL 17 · Redis · RabbitMQ · xUnit · Shouldly · NSubstitute · AutoMapper · Cypress (E2E) + +## Repository Structure + +``` +applications/Unity.GrantManager/ ← Main .NET solution (developers open this) +applications/Unity.AutoUI/ ← Cypress E2E tests (TypeScript) +database/scripts/ ← SQL seed/migration scripts +documentation/ ← Technical docs +.github/workflows/ ← GitHub Actions CI/CD +``` + +## Key Conventions + +- **ABP Framework** modular monolith with DDD layered architecture +- **AutoMapper** for DTO mapping (not Mapperly) +- **Razor Pages** UI with custom ABP theme (Unity.Theme.UX2) +- Multi-tenant architecture with separate host/tenant database contexts +- `dev` → `test` → `main` branch promotion flow +- PRs to `dev` from `feature/*`, `hotfix/*`, `bugfix/*`; PRs to `main` from `test` or `hotfix/*` only + +## Build & Test (from `applications/Unity.GrantManager/`) + +```bash +dotnet restore Unity.GrantManager.sln +dotnet build Unity.GrantManager.sln --no-restore +dotnet test Unity.GrantManager.sln --no-build +``` + +All PRs must pass `dotnet build` and `dotnet test` before merge. The CI runs all `*Tests.csproj` in a parallel matrix. + +## Do NOT + +- Use Mapperly patterns — this project uses AutoMapper +- Create repositories for child entities — only aggregate roots get repositories +- Put business logic in application services — use domain entities/services diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index 5f33222ea5..e1968299ec 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -63,7 +63,7 @@ jobs: environment: dev steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -93,7 +93,7 @@ jobs: actions: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -112,7 +112,7 @@ jobs: runs-on: ubuntu-latest environment: dev steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/docker-build-main.yml b/.github/workflows/docker-build-main.yml index c0294fe062..892ddbf95d 100644 --- a/.github/workflows/docker-build-main.yml +++ b/.github/workflows/docker-build-main.yml @@ -63,7 +63,7 @@ jobs: environment: main steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -93,7 +93,7 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Generate Next Git Tag @@ -146,7 +146,7 @@ jobs: actions: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -169,7 +169,7 @@ jobs: runs-on: ubuntu-latest environment: main steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index 3b7e9d91f0..96a43e594d 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -63,7 +63,7 @@ jobs: environment: test steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -91,7 +91,7 @@ jobs: environment: test steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Generate Next Git Tag @@ -118,7 +118,7 @@ jobs: environment: test steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -145,7 +145,7 @@ jobs: runs-on: ubuntu-latest environment: test steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/manual-trigger.yml b/.github/workflows/manual-trigger.yml index 31415a5681..8737a62c80 100644 --- a/.github/workflows/manual-trigger.yml +++ b/.github/workflows/manual-trigger.yml @@ -59,7 +59,7 @@ jobs: environment: ${{ inputs.name }} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Get short commitId @@ -88,7 +88,7 @@ jobs: actions: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: '1' - name: Set repository version variables @@ -107,7 +107,7 @@ jobs: runs-on: ubuntu-latest environment: ${{ inputs.name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build Docker images run: | rm -f ./docker-compose.override.yml diff --git a/.github/workflows/pr-check-dev-branch.yml b/.github/workflows/pr-check-dev-branch.yml index aaadd97269..80f6731abd 100644 --- a/.github/workflows/pr-check-dev-branch.yml +++ b/.github/workflows/pr-check-dev-branch.yml @@ -44,7 +44,7 @@ jobs: outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - id: discover run: | @@ -67,7 +67,7 @@ jobs: project: ${{ fromJson(needs.discover-test-projects.outputs.matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: diff --git a/.github/workflows/pr-check-main-branch.yml b/.github/workflows/pr-check-main-branch.yml index 205a2c9d8c..cbb297ba79 100644 --- a/.github/workflows/pr-check-main-branch.yml +++ b/.github/workflows/pr-check-main-branch.yml @@ -40,7 +40,7 @@ jobs: outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - id: discover run: | @@ -63,7 +63,7 @@ jobs: project: ${{ fromJson(needs.discover-test-projects.outputs.matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: diff --git a/.github/workflows/pr-check-test-branch.yml b/.github/workflows/pr-check-test-branch.yml index e900094d21..7deb972735 100644 --- a/.github/workflows/pr-check-test-branch.yml +++ b/.github/workflows/pr-check-test-branch.yml @@ -42,7 +42,7 @@ jobs: outputs: matrix: ${{ steps.discover.outputs.matrix }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - id: discover run: | @@ -65,7 +65,7 @@ jobs: project: ${{ fromJson(needs.discover-test-projects.outputs.matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: actions/setup-dotnet@v4 with: diff --git a/applications/Unity.AutoUI/cypress.config.ts b/applications/Unity.AutoUI/cypress.config.ts index c227e4bc3d..b1e381022d 100644 --- a/applications/Unity.AutoUI/cypress.config.ts +++ b/applications/Unity.AutoUI/cypress.config.ts @@ -1,22 +1,82 @@ -import { defineConfig } from 'cypress'; +import { defineConfig } from "cypress"; +import FormData from "form-data"; +import fs from "fs"; +import path from "path"; + // https://docs.cypress.io/guides/references/configuration export default defineConfig({ e2e: { - setupNodeEvents(on, config) { - // implement node event listeners here + setupNodeEvents(on) { + on("task", { + readJsonIfExists(filePath: string): Record | null { + try { + // path.resolve handles both forward and back slashes on Windows + const content = fs.readFileSync(path.resolve(filePath), "utf-8"); + return JSON.parse(content); + } catch { + return null; + } + }, + + async uploadChefsFile({ + baseURL, + authToken, + filePath, + }: { + baseURL: string; + authToken: string; + filePath: string; + }) { + const fileBuffer = fs.readFileSync(filePath); + const fileName = path.basename(filePath); + + const form = new FormData(); + form.append("files", fileBuffer, { + filename: fileName, + contentType: "text/plain", + }); + + // Use getBuffer() so fetch receives a complete binary buffer + // rather than a piped stream (which causes "Unexpected end of form") + const formBuffer = form.getBuffer(); + const formHeaders = form.getHeaders(); + + const response = await fetch(`${baseURL}/app/api/v1/files`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + ...formHeaders, + }, + body: formBuffer as unknown as BodyInit, + }); + + if (!response.ok) { + throw new Error( + `File upload failed: ${response.status} ${await response.text()}`, + ); + } + + return response.json(); + }, + }); }, - baseUrl: 'https://developer.gov.bc.ca/', + specPattern: [ + "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", + "cypress/scripts/**/*.cy.{js,jsx,ts,tsx}", + "cypress/regression/**/*.cy.{js,jsx,ts,tsx}", + ], + baseUrl: "https://dev-unity.apps.silver.devops.gov.bc.ca/", defaultCommandTimeout: 20000, // Time, in milliseconds, to wait until most DOM based commands are considered timed out. - viewportWidth: 1440, // Default width in pixels. - viewportHeight: 900, // Default height in pixels. + viewportWidth: 1440, // Default width in pixels. + viewportHeight: 900, // Default height in pixels. chromeWebSecurity: false, // Chromium-based browser's Web Security for same-origin policy and insecure mixed content. - testIsolation: false, // Set true to ensure a clean browser context between test cases. - retries: // The number of times to retry a failing test. - { - "runMode": 3, - "openMode": 0 - }, + testIsolation: false, // Set true to ensure a clean browser context between test cases. + // The number of times to retry a failing test. + retries: { + runMode: 3, + openMode: 0, + }, experimentalMemoryManagement: true, - numTestsKeptInMemory: 3 - } -}); \ No newline at end of file + numTestsKeptInMemory: 3, + }, +}); diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 0c08e11ce6..6ae85878e2 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -306,20 +306,23 @@ describe("Send an email", () => { }); it("Select Email Template", () => { - cy.intercept("GET", "/api/app/template/*/template-by-id").as( - "loadTemplate", - ); - cy.get("#template", { timeout: STANDARD_TIMEOUT }) .should("exist") .should("be.visible") .select(TEMPLATE_NAME); - cy.wait("@loadTemplate", { timeout: STANDARD_TIMEOUT }); - cy.get("#template") .find("option:selected") .should("have.text", TEMPLATE_NAME); + + // #EmailBody is a hidden textarea backing the rich-text editor. + // Template selection populates the visible RTE but does not auto-sync + // the backing field — trigger the change manually if still empty. + cy.get("#EmailBody", { timeout: STANDARD_TIMEOUT }).then(($el) => { + if (($el.val() as string).trim() === "") { + cy.wrap($el).invoke("val", "Test email body").trigger("change"); + } + }); }); it("Set Email To address", () => { diff --git a/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt b/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt new file mode 100644 index 0000000000..883ad3eacc --- /dev/null +++ b/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt @@ -0,0 +1,3 @@ +Maple Ridge Community Resource Development Initiative +Test Attachment - Automated Regression Submission +Generated by Cypress automation script. diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 6194b02f1f..550c95ff22 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -34,6 +34,13 @@ export class ApplicationDetailsPage extends BasePage { onHold: "#Application_OnHoldButton", }; + // Confirm action modal selectors (SweetAlert2) + private readonly confirmModal = { + modal: ".swal2-popup", + confirmButton: "button.swal2-confirm", + cancelButton: "button.swal2-cancel", + }; + // Field selectors for Summary/Info Panel private readonly summaryFields = { category: "Category", @@ -106,55 +113,63 @@ export class ApplicationDetailsPage extends BasePage { /** * Navigate to Submission tab */ - goToSubmissionTab(): void { + goToSubmissionTab(): this { this.clickElement(this.tabs.submission); + return this; } /** * Navigate to Review & Assessment tab */ - goToReviewAssessmentTab(): void { + goToReviewAssessmentTab(): this { + this.dismissErrorModalIfPresent(); this.clickElement(this.tabs.reviewAssessment); + return this; } /** * Navigate to Project Info tab */ - goToProjectInfoTab(): void { + goToProjectInfoTab(): this { this.clickElement(this.tabs.projectInfo); + return this; } /** * Navigate to Applicant Info tab */ - goToApplicantInfoTab(): void { + goToApplicantInfoTab(): this { this.clickElement(this.tabs.applicantInfo); + return this; } /** * Navigate to Funding Agreement tab */ - goToFundingAgreementTab(): void { + goToFundingAgreementTab(): this { this.clickElement(this.tabs.fundingAgreement); + return this; } /** * Navigate to Payment Info tab */ - goToPaymentInfoTab(): void { + goToPaymentInfoTab(): this { this.clickElement(this.tabs.paymentInfo); + return this; } /** * Verify all tabs are visible */ - verifyAllTabsVisible(): void { + verifyAllTabsVisible(): this { cy.get(this.tabs.submission).should("be.visible"); cy.get(this.tabs.reviewAssessment).should("be.visible"); cy.get(this.tabs.projectInfo).should("be.visible"); cy.get(this.tabs.applicantInfo).should("be.visible"); cy.get(this.tabs.fundingAgreement).should("be.visible"); cy.get(this.tabs.paymentInfo).should("be.visible"); + return this; } /** @@ -167,8 +182,8 @@ export class ApplicationDetailsPage extends BasePage { | "projectInfo" | "applicantInfo" | "fundingAgreement" - | "paymentInfo" - ): void { + | "paymentInfo", + ): this { const tabSelectors: Record = { submission: this.tabs.submission, reviewAssessment: this.tabs.reviewAssessment, @@ -178,6 +193,7 @@ export class ApplicationDetailsPage extends BasePage { paymentInfo: this.tabs.paymentInfo, }; cy.get(tabSelectors[tabName]).should("have.class", "active"); + return this; } /** @@ -294,14 +310,141 @@ export class ApplicationDetailsPage extends BasePage { this.verifyInputValue("#TotalBudgetInputAR", budget); } + // ============ Payment Info Methods ============ + + /** + * Enter Supplier Number + */ + enterSupplierNumber(supplierNumber: string): this { + cy.get("#SupplierNumber", { timeout: 20000 }) + .clear({ force: true }) + .type(supplierNumber, { force: true }) + .trigger("change") + .blur(); + return this; + } + + /** + * Click elsewhere to trigger save button enable + */ + clickElsewhere(): this { + cy.get("body").click(0, 0); + return this; + } + + /** + * Click Payment Info Save button + */ + clickPaymentInfoSave(): this { + cy.get("#nav-payment-info", { timeout: 20000 }) + .contains("button", "Save") + .click({ force: true }); + // Wait briefly for save to process + cy.wait(1000); + return this; + } + + /** + * Verify Site Info table is populated + */ + verifySiteInfoTablePopulated(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }).should( + "have.length.at.least", + 1, + ); + return this; + } + + /** + * Verify Site Info table has data in specific columns + */ + verifySiteInfoTableHasData(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) + .first() + .within(() => { + cy.get("td").eq(0).should("not.be.empty"); // Site # + cy.get("td").eq(1).should("not.be.empty"); // Pay Group + cy.get("td").eq(2).should("not.be.empty"); // Mailing Address + }); + return this; + } + + /** + * Click Edit button in Site Info table + */ + clickSiteInfoEdit(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) + .first() + .find("button, a") + .filter(':contains("Edit"), [title="Edit"], .edit-btn, .btn-edit') + .first() + .click({ force: true }); + return this; + } + + /** + * Wait for Edit Site modal to appear + */ + waitForEditSiteModal(): this { + cy.get(".modal-content", { timeout: 20000 }) + .contains(".modal-title", "Edit Site") + .should("be.visible"); + return this; + } + + /** + * Select Payment Group in Edit Site modal + */ + selectPaymentGroup(paymentGroup: "EFT" | "Cheque"): this { + cy.get("#Site_PaymentGroup", { timeout: 20000 }).select(paymentGroup, { + force: true, + }); + return this; + } + + /** + * Click Save Changes in Edit Site modal + */ + clickSaveChanges(): this { + cy.get(".modal-footer", { timeout: 20000 }) + .contains("button", "SAVE CHANGES") + .click({ force: true }); + cy.wait(2000); // Wait for save to process + cy.get("body").type("{esc}"); + cy.get(".modal.show, .modal.fade.show", { timeout: 20000 }).should( + "not.exist", + ); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + return this; + } + + /** + * Click Cancel in Edit Site modal + */ + clickModalCancel(): this { + cy.get(".modal-footer", { timeout: 20000 }) + .contains("button", "CANCEL") + .click({ force: true }); + return this; + } + // ============ Status Actions Dropdown Methods ============ /** * Open the Status Actions dropdown */ openStatusActionsDropdown(): void { - this.clickElement(this.statusActions.dropdownToggle); - cy.get(this.statusActions.dropdownMenu).should("be.visible"); + cy.get(this.statusActions.dropdownMenu).then(($menu) => { + if (!$menu.is(":visible")) { + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }) + .should("exist") + .scrollIntoView() + .click({ force: true }); + } + }); + cy.get(this.statusActions.dropdownMenu, { timeout: 10000 }).should( + "be.visible", + ); } /** @@ -337,11 +480,43 @@ export class ApplicationDetailsPage extends BasePage { } /** - * Click Approve action + * Click Approve action. + * If "Complete Assessment" is present and enabled in the dropdown, click it first + * (and confirm any resulting dialog), then reopen the dropdown before clicking Approve. */ - clickApprove(): void { + clickApprove(): this { this.openStatusActionsDropdown(); - this.clickElement(this.statusActions.approve); + + // Use body-check so we never timeout when the button is absent from the DOM + cy.get("body").then(($body) => { + const $completeBtn = $body.find(this.statusActions.completeAssessment); + if ($completeBtn.length > 0 && !$completeBtn.is(":disabled")) { + cy.wait(10000); // Wait for any potential UI updates before clicking + cy.wrap($completeBtn).click(); + cy.wait(2000); // Wait for any potential UI updates after clicking + cy.get("body").then(($b) => { + if ($b.find(this.confirmModal.modal).filter(":visible").length > 0) { + cy.get(this.confirmModal.modal) + .find(this.confirmModal.confirmButton) + .click({ force: true }); + } + }); + this.dismissErrorModalIfPresent(); + + // Wait for the page to stabilize before reopening the dropdown + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }).should( + "be.visible", + ); + cy.wait(2000); + } + }); + + // Always reopen dropdown fresh before clicking Approve (dropdown may have closed) + this.openStatusActionsDropdown(); + cy.get(this.statusActions.approve, { timeout: 10000 }) + .should("exist") + .click({ force: true }); + return this; } /** @@ -384,6 +559,56 @@ export class ApplicationDetailsPage extends BasePage { this.clickElement(this.statusActions.onHold); } + // ============ Confirm Modal Methods ============ + + /** + * Wait for confirm action modal to appear (SweetAlert2) + */ + waitForConfirmModal(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }).should("be.visible"); + return this; + } + + /** + * Click Confirm button in the modal (SweetAlert2) + */ + clickConfirm(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }) + .find(this.confirmModal.confirmButton) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Cancel button in the modal (SweetAlert2) + */ + clickCancel(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }) + .find(this.confirmModal.cancelButton) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Dismiss any error modal if present (SweetAlert2) + * Uses failOnStatusCode: false to not fail if no modal exists + */ + dismissErrorModalIfPresent(): this { + cy.get("body").then(($body) => { + // Only dismiss if it is specifically an error modal (swal2-error icon) + if ($body.find(".swal2-container .swal2-icon.swal2-error").length > 0) { + cy.get(".swal2-container") + .find(".swal2-confirm") + .first() + .click({ force: true }); + cy.wait(500); + } + }); + return this; + } + /** * Verify status action is enabled */ @@ -398,7 +623,7 @@ export class ApplicationDetailsPage extends BasePage { | "close" | "withdraw" | "defer" - | "onHold" + | "onHold", ): void { const actionSelectors: Record = { startReview: this.statusActions.startReview, @@ -430,7 +655,7 @@ export class ApplicationDetailsPage extends BasePage { | "close" | "withdraw" | "defer" - | "onHold" + | "onHold", ): void { const actionSelectors: Record = { startReview: this.statusActions.startReview, diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts new file mode 100644 index 0000000000..1115fb4967 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts @@ -0,0 +1,644 @@ +/// + +import { BasePage } from "./BasePage"; + +/** + * ApplicationDetailsRightTabPage - Page Object for the right panel tabs on Application Details + * Handles: Details, Emails, Comments, Attachments, Links, History tabs + */ +export class ApplicationDetailsRightTabPage extends BasePage { + private readonly STANDARD_TIMEOUT = 20000; + + // Right panel container + private readonly container = ".right-card"; + + // Tab button selectors + private readonly tabs = { + details: "#details-tab", + emails: "#emails-tab", + comments: "#comments-tab", + attachments: "#attachments-tab", + links: "#links-tab", + history: "#history-tab", + }; + + // Tab content selectors + private readonly tabContent = { + details: "#details", + emails: "#emails", + comments: "#comments", + attachments: "#attachments", + links: "#links", + history: "#history", + }; + + // Count badge selectors + private readonly countBadges = { + emails: "#application_emails_count", + comments: "#application_comments_count", + attachments: "#application_attachment_count", + links: "#application_links_count", + }; + + // Details tab selectors + private readonly detailsSection = { + applicationStatusWidget: "#applicationStatusWidget", + applicationTagsWidget: "#applicationTagsWidget", + summaryWidgetArea: "#summaryWidgetArea", + summaryTable: ".summary-table", + }; + + // Assessment section selectors (in Details tab) + private readonly assessmentSection = { + reviewDetails: "#reviewDetails", + assessmentId: "#AssessmentId", + financialAnalysis: "#financialAnalysis", + economicImpact: "#economicImpact", + inclusiveGrowth: "#inclusiveGrowth", + cleanGrowth: "#cleanGrowth", + subTotal: "#subTotal", + saveAssessmentScoresBtn: "#saveAssessmentScoresBtn", + recommendationSelect: "#recommendation_select", + recommendationResetBtn: "#recommendation_reset_btn", + }; + + // Email section selectors + private readonly emailSection = { + newEmailBtn: "#btn-new-email", + emailForm: "#EmailForm", + templateSelect: "#template", + emailTo: "#EmailTo", + emailCC: "#EmailCC", + emailBCC: "#EmailBCC", + emailFrom: "#EmailFrom", + emailSubject: "#EmailSubject", + emailBody: "#EmailBody", + saveBtn: "#btn-save", + sendBtn: "#btn-send", + cancelBtn: "#btn-cancel-email", + confirmSendBtn: "#btn-confirm-send", + }; + + // Comments section selectors + private readonly commentsSection = { + commentTextArea: "#comments .comment-input", + addCommentSaveBtn: "#comments .add-comment-save-button", + addCommentCancelBtn: "#comments .add-comment-cancel-button", + commentsContainer: "#comments .comments-container", + }; + + // Attachments section selectors + private readonly attachmentsSection = { + attachmentsTable: "#ApplicationAttachmentsTable", + submissionAttachmentsTable: ".submission-attachments-table, [id*='SubmissionAttachments']", + uploadBtn: "#application_upload_btn", + uploadInput: "#application_upload", + addAttachmentsBtn: "button:contains('Add Attachments'), .add-attachments-btn, [id*='addAttachment']", + }; + + // Links section selectors + private readonly linksSection = { + linksTable: "#ApplicationLinksTable", + addLinkBtn: "#addLinkBtn", + }; + + // History section selectors + private readonly historySection = { + historyTable: "#ApplicationHistoryTable", + }; + + constructor() { + super(); + } + + // ============ Tab Navigation Methods ============ + + /** + * Go to Details tab + */ + goToDetailsTab(): this { + cy.get(this.tabs.details, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Emails tab + */ + goToEmailsTab(): this { + cy.get(this.tabs.emails, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Comments tab + */ + goToCommentsTab(): this { + cy.get(this.tabs.comments, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Attachments tab + */ + goToAttachmentsTab(): this { + cy.get(this.tabs.attachments, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to Links tab + */ + goToLinksTab(): this { + cy.get(this.tabs.links, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Go to History tab + */ + goToHistoryTab(): this { + cy.get(this.tabs.history, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Verify active tab + */ + verifyActiveTab( + tabName: "details" | "emails" | "comments" | "attachments" | "links" | "history" + ): this { + cy.get(this.tabs[tabName], { timeout: this.STANDARD_TIMEOUT }) + .should("have.class", "active"); + return this; + } + + // ============ Count Badge Methods ============ + + /** + * Get emails count + */ + getEmailsCount(): Cypress.Chainable { + return cy + .get(this.countBadges.emails, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + /** + * Get comments count + */ + getCommentsCount(): Cypress.Chainable { + return cy + .get(this.countBadges.comments, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + /** + * Get attachments count + */ + getAttachmentsCount(): Cypress.Chainable { + return cy + .get(this.countBadges.attachments, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + /** + * Get links count + */ + getLinksCount(): Cypress.Chainable { + return cy + .get(this.countBadges.links, { timeout: this.STANDARD_TIMEOUT }) + .invoke("text") + .then((text) => parseInt(text, 10) || 0); + } + + // ============ Details Tab Methods ============ + + /** + * Verify application status + */ + verifyApplicationStatus(expectedStatus: string): this { + cy.get(this.detailsSection.applicationStatusWidget, { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", expectedStatus); + return this; + } + + /** + * Get summary field value by label + */ + getSummaryFieldValue(label: string): Cypress.Chainable { + return cy + .get(this.detailsSection.summaryTable, { timeout: this.STANDARD_TIMEOUT }) + .contains(".display-input-label", label) + .siblings(".display-input") + .invoke("text") + .then((text) => text.trim()); + } + + /** + * Verify summary field value + */ + verifySummaryFieldValue(label: string, expectedValue: string): this { + cy.get(this.detailsSection.summaryTable, { timeout: this.STANDARD_TIMEOUT }) + .contains(".display-input-label", label) + .siblings(".display-input") + .should("contain.text", expectedValue); + return this; + } + + // ============ Assessment Scores Methods ============ + + /** + * Enter financial analysis score + */ + enterFinancialAnalysis(score: string): this { + cy.get(this.assessmentSection.financialAnalysis, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Enter economic impact score + */ + enterEconomicImpact(score: string): this { + cy.get(this.assessmentSection.economicImpact, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Enter inclusive growth score + */ + enterInclusiveGrowth(score: string): this { + cy.get(this.assessmentSection.inclusiveGrowth, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Enter clean growth score + */ + enterCleanGrowth(score: string): this { + cy.get(this.assessmentSection.cleanGrowth, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(score); + return this; + } + + /** + * Click save assessment scores button + */ + clickSaveAssessmentScores(): this { + cy.get(this.assessmentSection.saveAssessmentScoresBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("not.be.disabled") + .click({ force: true }); + return this; + } + + /** + * Select recommendation + */ + selectRecommendation(recommendation: "true" | "false"): this { + cy.get(this.assessmentSection.recommendationSelect, { timeout: this.STANDARD_TIMEOUT }) + .select(recommendation); + return this; + } + + /** + * Click reset recommendation button + */ + clickResetRecommendation(): this { + cy.get(this.assessmentSection.recommendationResetBtn, { timeout: this.STANDARD_TIMEOUT }) + .click({ force: true }); + return this; + } + + // ============ Email Methods ============ + + /** + * Click New Email button + */ + clickNewEmail(): this { + cy.get(this.emailSection.newEmailBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Select email template + */ + selectEmailTemplate(templateName: string): this { + cy.get(this.emailSection.templateSelect, { timeout: this.STANDARD_TIMEOUT }) + .select(templateName); + return this; + } + + /** + * Enter email To address + */ + enterEmailTo(email: string): this { + cy.get(this.emailSection.emailTo, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(email); + return this; + } + + /** + * Enter email CC address + */ + enterEmailCC(email: string): this { + cy.get(this.emailSection.emailCC, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(email); + return this; + } + + /** + * Enter email BCC address + */ + enterEmailBCC(email: string): this { + cy.get(this.emailSection.emailBCC, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(email); + return this; + } + + /** + * Enter email subject + */ + enterEmailSubject(subject: string): this { + cy.get(this.emailSection.emailSubject, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(subject); + return this; + } + + /** + * Enter email body + */ + enterEmailBody(body: string): this { + cy.get(this.emailSection.emailBody, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(body); + return this; + } + + /** + * Click Save email button + */ + clickSaveEmail(): this { + cy.get(this.emailSection.saveBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Send email button + */ + clickSendEmail(): this { + cy.get(this.emailSection.sendBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Confirm Send email button + */ + clickConfirmSendEmail(): this { + cy.get(this.emailSection.confirmSendBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Cancel email button + */ + clickCancelEmail(): this { + cy.get(this.emailSection.cancelBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + // ============ Comments Methods ============ + + /** + * Add a comment + */ + addComment(comment: string): this { + cy.get(this.commentsSection.commentTextArea, { timeout: this.STANDARD_TIMEOUT }) + .first() + .clear() + .type(comment); + return this; + } + + /** + * Click save comment button + */ + clickSaveComment(): this { + cy.get(this.commentsSection.addCommentSaveBtn, { timeout: this.STANDARD_TIMEOUT }) + .first() + .click({ force: true }); + return this; + } + + /** + * Click cancel comment button + */ + clickCancelComment(): this { + cy.get(this.commentsSection.addCommentCancelBtn, { timeout: this.STANDARD_TIMEOUT }) + .first() + .click({ force: true }); + return this; + } + + /** + * Verify comment exists + */ + verifyCommentExists(commentText: string): this { + cy.get(this.commentsSection.commentsContainer, { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", commentText); + return this; + } + + // ============ Attachments Methods ============ + + /** + * Click upload attachment button + */ + clickUploadAttachment(): this { + cy.get(this.attachmentsSection.uploadBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Upload file attachment + * Uses the Submission Attachments file input + */ + uploadAttachment(filePath: string): this { + // Try the submission attachments file input first + const selectors = [ + "#addSubmissionAttachmentsFile", + "#application_upload", + "#attachments input[type='file']", + "input[type='file'][id*='attachment']", + "input[type='file'][id*='Attachment']", + "input[type='file']", + ]; + + cy.get("body").then(($body) => { + let fileInput = null; + + for (const selector of selectors) { + const $el = $body.find(selector); + if ($el.length > 0) { + fileInput = selector; + break; + } + } + + if (fileInput) { + cy.get(fileInput).first().selectFile(filePath, { force: true }); + } else { + // Click Add Attachments button to trigger file input + cy.contains("Add Attachments", { timeout: this.STANDARD_TIMEOUT }) + .click({ force: true }); + cy.wait(500); + cy.get("input[type='file']").first().selectFile(filePath, { force: true }); + } + }); + + // Wait for upload to complete + cy.wait(3000); + return this; + } + + /** + * Upload a unique attachment with generated content + * @param fileName - The filename to use + * @param timestamp - Timestamp for unique content + */ + uploadUniqueAttachment(fileName: string, timestamp: number): this { + cy.get("#attachments input[type='file']", { timeout: this.STANDARD_TIMEOUT }) + .first() + .selectFile( + { + contents: Cypress.Buffer.from(`Test attachment content - ${timestamp}`), + fileName: fileName, + mimeType: "text/plain", + }, + { force: true } + ); + return this; + } + + /** + * Verify attachments table has rows + */ + verifyAttachmentsTableHasRows(): this { + cy.get(this.attachmentsSection.attachmentsTable, { timeout: this.STANDARD_TIMEOUT }) + .find("tbody tr") + .should("have.length.at.least", 1); + return this; + } + + /** + * Verify attachment exists by name + */ + verifyAttachmentExists(fileName: string): this { + cy.get("#attachments", { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", fileName); + return this; + } + + // ============ Links Methods ============ + + /** + * Click add link button + */ + clickAddLink(): this { + cy.get(this.linksSection.addLinkBtn, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Verify links table has rows + */ + verifyLinksTableHasRows(): this { + cy.get(this.linksSection.linksTable, { timeout: this.STANDARD_TIMEOUT }) + .find("tbody tr") + .should("have.length.at.least", 1); + return this; + } + + // ============ History Methods ============ + + /** + * Verify history table has rows + */ + verifyHistoryTableHasRows(): this { + cy.get(this.historySection.historyTable, { timeout: this.STANDARD_TIMEOUT }) + .find("tbody tr") + .should("have.length.at.least", 1); + return this; + } + + /** + * Verify history contains action + */ + verifyHistoryContainsAction(action: string): this { + cy.get(this.historySection.historyTable, { timeout: this.STANDARD_TIMEOUT }) + .should("contain.text", action); + return this; + } + + // ============ Verification Methods ============ + + /** + * Verify right panel is visible + */ + verifyRightPanelVisible(): this { + cy.get(this.container, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible"); + return this; + } + + /** + * Verify all tabs are visible + */ + verifyAllTabsVisible(): this { + cy.get(this.tabs.details, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.emails, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.comments, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.attachments, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.links, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + cy.get(this.tabs.history, { timeout: this.STANDARD_TIMEOUT }).should("be.visible"); + return this; + } +} diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index aae8eb72bb..108d6b0cfe 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -186,6 +186,39 @@ export class ApplicationsListPage extends ApplicationsPage { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } + // ============ Search Methods ============ + + /** + * Search for a submission by ID + */ + searchForSubmission(submissionId: string): this { + cy.get("#search", { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(submissionId); + this.waitForTableRefresh(); + return this; + } + + /** + * Select a row by matching text content + */ + selectRowByText(text: string): this { + cy.contains("tr", text, { timeout: this.STANDARD_TIMEOUT }) + .find(".checkbox-select") + .click({ force: true }); + return this; + } + + /** + * Click the OPEN button (external link) + */ + clickOpenButton(): this { + cy.get("#externalLink", { timeout: this.STANDARD_TIMEOUT }) + .should("exist") + .click({ force: true }); + return this; + } + // ============ Extended Table Methods ============ /** diff --git a/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts new file mode 100644 index 0000000000..b5f2636ef9 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts @@ -0,0 +1,449 @@ +/// + +import { BasePage } from "./BasePage"; + +/** + * ReviewAssessmentPage - Page Object for the Review & Assessment tab + * Handles form fields inside shadow DOM (Form.io) + */ +export class ReviewAssessmentPage extends BasePage { + private readonly STANDARD_TIMEOUT = 20000; + + // Tab container selectors + private readonly containers = { + detailsTabContent: "#detailsTabContent", + submissionTab: "#nav-summery", + reviewAssessmentTab: "#nav-review-and-assessment", + formioContainer: "#formio", + }; + + // Section header selectors (card headers) + private readonly sections = { + introduction: 'h4.card-title:contains("1. INTRODUCTION")', + eligibility: 'h4.card-title:contains("2. ELIGIBILITY")', + applicantInfo: 'h4.card-title:contains("3. APPLICANT INFORMATION")', + projectInfo: 'h4.card-title:contains("4. PROJECT INFORMATION")', + projectTimelines: 'h4.card-title:contains("5. PROJECT TIMELINES")', + projectBudget: 'h4.card-title:contains("6. PROJECT BUDGET")', + attestation: 'h4.card-title:contains("7. ATTESTATION")', + }; + + // Organization Info panel selectors (using name attribute) + private readonly organizationInfo = { + applicantName: 'input[name="data[_ApplicantName]"]', + registeredBusinessName: 'input[name="data[_dateExtractBusinessName]"]', + registeredBusinessNumber: 'input[name="data[_registeredBusinessNumber]"]', + businessName: 'input[name="data[_OrganizationName]"]', + organizationType: 'select[name="data[_OrganizationType]"]', + orgBookStatus: 'select[name="data[_OrgBookStatus]"]', + riskRanking: 'select[name="data[_riskRanking]"]', + sector: 'select[name="data[sector]"]', + subsector: 'select[name="data[subsector]"]', + otherSubsector: 'textarea[name="data[_OtherSubSector]"]', + }; + + // Contact Info panel selectors + private readonly contactInfo = { + contactName: 'input[name="data[_ContactName]"]', + contactTitle: 'input[name="data[_ContactTitle]"]', + contactEmail: 'input[name="data[_ContactEmail]"]', + contactPhonePrimary: 'input[name="data[_ContactPhoneNumberPrimary]"]', + contactPhoneSecondary: 'input[name="data[_ContactPhoneNumberSecondary]"]', + }; + + // Mailing Address panel selectors + private readonly mailingAddress = { + unit: 'input[name="data[_MailingAddressUnit]"]', + street1: 'input[name="data[_MailingAddressStreet1]"]', + street2: 'input[name="data[_MailingAddressStreet2]"]', + city: 'input[name="data[_MailingAddressCity]"]', + province: 'select[name="data[_MailingAddressProvince]"]', + postalCode: 'input[name="data[_MailingAddressPostalCode]"]', + }; + + // Project Info selectors + private readonly projectInfo = { + projectName: 'input[name="data[_ProjectName]"]', + projectDescription: 'textarea[name="data[_ProjectDescription]"]', + economicRegion: 'select[name="data[_EconomicRegion]"]', + regionalDistrict: 'select[name="data[_RegionalDistrict]"]', + community: 'select[name="data[_Community]"]', + }; + + // Project Budget selectors + private readonly projectBudget = { + requestedAmount: 'input[name="data[_RequestedAmount]"]', + totalProjectBudget: 'input[name="data[_TotalProjectBudget]"]', + }; + + // Assessment selectors + private readonly assessment = { + approvedAmount: '#ApprovalView_ApprovedAmount', + decisionDate: '#ApprovalView_FinalDecisionDate', + saveButton: 'button:contains("Save")', + // Assessment List view buttons + createAssessmentButton: '#CreateButton', + completeAssessmentButton: '#CompleteButton', + assessmentMainView: '#assessmentMainView', + }; + + constructor() { + super(); + } + + // ============ Section Methods ============ + + /** + * Expand a section by clicking its header + */ + expandSection( + sectionName: + | "introduction" + | "eligibility" + | "applicantInfo" + | "projectInfo" + | "projectTimelines" + | "projectBudget" + | "attestation" + ): this { + cy.contains("h4.card-title", this.getSectionTitle(sectionName), { + timeout: this.STANDARD_TIMEOUT, + }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Get the full section title text + */ + private getSectionTitle(sectionName: string): string { + const titles: Record = { + introduction: "1. INTRODUCTION", + eligibility: "2. ELIGIBILITY", + applicantInfo: "3. APPLICANT INFORMATION", + projectInfo: "4. PROJECT INFORMATION", + projectTimelines: "5. PROJECT TIMELINES", + projectBudget: "6. PROJECT BUDGET", + attestation: "7. ATTESTATION", + }; + return titles[sectionName] || sectionName; + } + + /** + * Verify a section exists + */ + verifySectionExists( + sectionName: + | "introduction" + | "eligibility" + | "applicantInfo" + | "projectInfo" + | "projectTimelines" + | "projectBudget" + | "attestation" + ): this { + cy.contains("h4.card-title", this.getSectionTitle(sectionName), { + timeout: this.STANDARD_TIMEOUT, + }).should("exist"); + return this; + } + + // ============ Organization Info Methods ============ + + /** + * Get applicant name value + */ + getApplicantName(): Cypress.Chainable { + return cy + .get(this.organizationInfo.applicantName, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify applicant name + */ + verifyApplicantName(expectedValue: string): this { + cy.get(this.organizationInfo.applicantName, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Get registered business name + */ + getRegisteredBusinessName(): Cypress.Chainable { + return cy + .get(this.organizationInfo.registeredBusinessName, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify registered business name + */ + verifyRegisteredBusinessName(expectedValue: string): this { + cy.get(this.organizationInfo.registeredBusinessName, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Get registered business number + */ + getRegisteredBusinessNumber(): Cypress.Chainable { + return cy + .get(this.organizationInfo.registeredBusinessNumber, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify registered business number + */ + verifyRegisteredBusinessNumber(expectedValue: string): this { + cy.get(this.organizationInfo.registeredBusinessNumber, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + // ============ Contact Info Methods ============ + + /** + * Verify contact name + */ + verifyContactName(expectedValue: string): this { + cy.get(this.contactInfo.contactName, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact title + */ + verifyContactTitle(expectedValue: string): this { + cy.get(this.contactInfo.contactTitle, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact email + */ + verifyContactEmail(expectedValue: string): this { + cy.get(this.contactInfo.contactEmail, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact phone primary + */ + verifyContactPhonePrimary(expectedValue: string): this { + cy.get(this.contactInfo.contactPhonePrimary, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify contact phone secondary + */ + verifyContactPhoneSecondary(expectedValue: string): this { + cy.get(this.contactInfo.contactPhoneSecondary, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + // ============ Mailing Address Methods ============ + + /** + * Verify mailing address city + */ + verifyMailingCity(expectedValue: string): this { + cy.get(this.mailingAddress.city, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify mailing address street 1 + */ + verifyMailingStreet1(expectedValue: string): this { + cy.get(this.mailingAddress.street1, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify mailing address postal code + */ + verifyMailingPostalCode(expectedValue: string): this { + cy.get(this.mailingAddress.postalCode, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + // ============ Panel Methods ============ + + /** + * Expand Organization Info panel + */ + expandOrganizationInfoPanel(): this { + cy.contains(".card-header", "Organization Info", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Expand Contact Info panel + */ + expandContactInfoPanel(): this { + cy.contains(".card-header", "Contact Info", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Expand Mailing Address panel + */ + expandMailingAddressPanel(): this { + cy.contains(".card-header", "Mailing Address", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + // ============ Verification Methods ============ + + /** + * Verify all main sections are present + */ + verifyAllSectionsPresent(): this { + this.verifySectionExists("introduction"); + this.verifySectionExists("eligibility"); + this.verifySectionExists("applicantInfo"); + this.verifySectionExists("projectInfo"); + this.verifySectionExists("projectTimelines"); + this.verifySectionExists("projectBudget"); + this.verifySectionExists("attestation"); + return this; + } + + /** + * Verify formio container is loaded + */ + verifyFormioLoaded(): this { + cy.get(this.containers.formioContainer, { timeout: this.STANDARD_TIMEOUT }) + .should("exist"); + return this; + } + + /** + * Get field value by name attribute + */ + getFieldValue(fieldName: string): Cypress.Chainable { + return cy + .get(`input[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + .invoke("val") + .then((val) => String(val)); + } + + /** + * Verify field value by name attribute + */ + verifyFieldValue(fieldName: string, expectedValue: string): this { + cy.get(`input[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + .should("have.value", expectedValue); + return this; + } + + /** + * Verify select field has expected text (for Choices.js dropdowns) + */ + verifySelectFieldText(fieldName: string, expectedText: string): this { + cy.get(`select[name="data[${fieldName}]"]`, { timeout: this.STANDARD_TIMEOUT }) + .parent() + .find(".choices__item--selectable") + .should("contain.text", expectedText); + return this; + } + + // ============ Assessment Methods ============ + + /** + * Enter approved amount + */ + enterApprovedAmount(amount: string): this { + cy.get(this.assessment.approvedAmount, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(amount); + return this; + } + + /** + * Set decision date to today (format: YYYY-MM-DD) + */ + setDecisionDateToToday(): this { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const today = `${yyyy}-${mm}-${dd}`; + cy.get(this.assessment.decisionDate, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(today); + return this; + } + + /** + * Set decision date to a specific date + */ + setDecisionDate(date: string): this { + cy.get(this.assessment.decisionDate, { timeout: this.STANDARD_TIMEOUT }) + .clear() + .type(date); + return this; + } + + /** + * Click Save button + */ + clickSave(): this { + cy.contains("button", "Save", { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Scroll to Assessment List section + */ + scrollToAssessmentList(): this { + cy.get(this.assessment.assessmentMainView, { timeout: this.STANDARD_TIMEOUT }) + .scrollIntoView(); + return this; + } + + /** + * Click Create Assessment button in Assessment List view + */ + clickCreateAssessment(): this { + cy.get(this.assessment.createAssessmentButton, { timeout: this.STANDARD_TIMEOUT }) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Complete Assessment button in Assessment List view + */ + clickCompleteAssessment(): this { + cy.get(this.assessment.completeAssessmentButton, { timeout: this.STANDARD_TIMEOUT }) + .should("not.be.disabled") + .click({ force: true }); + return this; + } +} diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts new file mode 100644 index 0000000000..5b480b2aab --- /dev/null +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -0,0 +1,401 @@ +/// + +/** + * Approval Flow Regression Test - Full Approval Workflow + * + * This test validates the complete application approval workflow including: + * - Submission ID resolution (seeded file → static override → dynamic API fetch) + * - Searching and opening a submission + * - Review and assessment process + * - Payment info configuration + * - Adding comments and attachments + * - Application approval (confirmed via dialog) + * - Payment request submission + * - L1 and L2 payment approvals (two separate users) + * - Post-approval status and date validation on the Payments table + */ + +import { ApplicationsListPage } from "../pages/ApplicationsListPage"; +import { ApplicationDetailsPage } from "../pages/ApplicationDetailsPage"; +import { ReviewAssessmentPage } from "../pages/ReviewAssessmentPage"; +import { ApplicationDetailsRightTabPage } from "../pages/ApplicationDetailsRightTabPage"; +import { NavigationPage } from "../pages/NavigationPage"; +import { loginIfNeeded } from "../support/auth"; + +const isProd = + ( + Cypress.env("CHEFS_ENV") || + Cypress.env("environment") || + "" + ).toLowerCase() === "prod"; + +// ============ Test Configuration ============ +const TEST_CONFIG = { + // Set to null to resolve ID automatically, or provide a value to force a specific submission + submissionId: null as string | null, + grantProgram: "Default Grants Program", + approvedAmount: "5000", + supplierNumber: Cypress.env("environment") === "TEST" ? "2002712" : "2009366", + paymentGroup: "Cheque" as const, + testComment: "Test comment from automated regression test", + // Options used when fetching dynamically (ignored when submissionId is set or seeded file exists) + fetchOptions: { + categoryFilter: "Data Seeder", + // Available statuses: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' + statusFilter: ["Submitted"], + maxAge: 30, // Only consider submissions created within the last N days + index: 0, // 0 = latest; increment to avoid collision with concurrent tests + }, +}; + +(isProd ? describe.skip : describe)("Approval Flow Regression Test", () => { + // Page object instances reused across all tests + const listPage = new ApplicationsListPage(); + const detailsPage = new ApplicationDetailsPage(); + const reviewPage = new ReviewAssessmentPage(); + const rightTabPage = new ApplicationDetailsRightTabPage(); + const navPage = new NavigationPage(); + + // Resolved after "Fetch submission ID from API" — shared across all subsequent tests + let submissionId: string; + + // ============ Shared Helpers ============ + + /** Navigate to the Payments tab and filter the table by submissionId. */ + function navigateToPaymentsAndSearch(): void { + cy.reload(); + navPage.goToPayments(); + cy.location("pathname", { timeout: 20000 }).should("include", "Payment"); + cy.get("#search", { timeout: 20000 }) + .should("be.visible") + .clear() + .type(submissionId); + cy.contains("tr", submissionId, { timeout: 20000 }).should("exist"); + } + + /** Select the submissionId row and open the Approve Payments modal. */ + function selectRowAndOpenApproveModal(): void { + cy.contains("tr", submissionId, { timeout: 20000 }) + .find(".checkbox-select") + .click({ force: true }); + cy.contains("button", "Approve", { timeout: 20000 }) + .should("be.visible") + .click(); + cy.contains(".modal-title", "Approve Payments", { timeout: 20000 }).should( + "be.visible", + ); + } + + /** + * Validate the Approve Payments modal fields, enter an auto-generated note, + * submit the approval and assert the modal closes. + * @param notePrefix - Short prefix to distinguish L1 vs L2 notes (max length enforced at 50 chars total) + */ + function validateAndSubmitApproveModal(notePrefix: string): void { + cy.get("#ApplicationCount").should("have.value", "1"); + cy.get("#UpdateTotalAmount").should("not.have.value", "0"); + cy.get("#Note").should("be.visible"); + + const approvalNote = `${notePrefix}-${submissionId}-${Date.now()}`.slice( + 0, + 50, + ); + cy.get("#Note").clear().type(approvalNote); + + cy.get("#btnSubmitPayment") + .should("be.visible") + .and("not.be.disabled") + .click(); + + cy.contains(".modal-title", "Approve Payments", { timeout: 20000 }).should( + "not.exist", + ); + cy.log(`✅ Payment approved with note: ${approvalNote}`); + } + + // ============ Setup ============ + + before(() => { + Cypress.config("includeShadowDom", true); + loginIfNeeded(); + }); + + // ============ Dynamic Submission Fetch ============ + + it("Fetch submission ID from API", () => { + // Priority 1: ID written by the seed script when running test:approval-flow + cy.task("readJsonIfExists", "cypress/scripts/last-submission-id.json").then( + (result) => { + const seeded = result as { + submissionId?: string; + createdAt?: string; + } | null; + if (seeded?.submissionId) { + submissionId = seeded.submissionId; + cy.log(`📌 Using seeded submission ID: ${submissionId}`); + // Clear file to prevent reuse on the next standalone run + cy.writeFile("cypress/scripts/last-submission-id.json", {}); + return; + } + + // Priority 2: static override in TEST_CONFIG + if (TEST_CONFIG.submissionId) { + submissionId = TEST_CONFIG.submissionId; + cy.log(`📌 Using static submission ID: ${submissionId}`); + return; + } + + // Priority 3: fetch the latest matching submission from the Unity API + cy.fetchDynamicSubmission(TEST_CONFIG.fetchOptions).then((id) => { + submissionId = id; + cy.log(`✅ Fetched dynamic submission ID: ${submissionId}`); + }); + }, + ); + }); + + // ============ Navigation & Search ============ + + it("Switch to grant program", () => { + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + }); + + it("Search for submission", () => { + expect(submissionId, "Submission ID should be set").to.exist; + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId); + }); + + it("Select submission and open details", () => { + listPage.selectRowByText(submissionId).clickOpenButton(); + }); + + // ============ Review & Assessment ============ + + it("Navigate to Review and Assessment tab", () => { + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + }); + + it("Enter approval details and save", () => { + reviewPage + .verifyFormioLoaded() + .enterApprovedAmount(TEST_CONFIG.approvedAmount) + .setDecisionDateToToday() + .clickSave(); + }); + + it("Create and complete assessment", () => { + cy.wait(2000); // Allow assessment section to fully load + reviewPage.scrollToAssessmentList(); + + cy.get("body").then(($body) => { + if ($body.find("#CreateButton").length > 0) { + cy.get("#CreateButton").click({ force: true }); + cy.wait(1000); + } else { + cy.log("Create Assessment button not found - may already be created"); + } + }); + + cy.get("body").then(($body) => { + if ($body.find("#CompleteButton").length > 0) { + cy.get("#CompleteButton").click({ force: true }); + cy.wait(1000); + } else { + cy.log( + "Complete Assessment button not found - may already be completed", + ); + } + }); + }); + + // ============ Payment Info ============ + + it("Configure payment info", () => { + cy.reload(); // Reload to get fresh data and avoid concurrency issues + cy.wait(2000); + detailsPage + .goToPaymentInfoTab() + .enterSupplierNumber(TEST_CONFIG.supplierNumber) + .clickElsewhere() + .clickPaymentInfoSave(); + }); + + it("Validate and edit site info", () => { + detailsPage + .verifySiteInfoTablePopulated() + .verifySiteInfoTableHasData() + .clickSiteInfoEdit() + .waitForEditSiteModal() + .selectPaymentGroup(TEST_CONFIG.paymentGroup) + .clickSaveChanges(); + }); + + // ============ Comments & Attachments ============ + + it("Add a comment", () => { + detailsPage.dismissErrorModalIfPresent(); + rightTabPage + .goToCommentsTab() + .addComment(TEST_CONFIG.testComment) + .clickSaveComment(); + }); + + it("Add an attachment", () => { + detailsPage.dismissErrorModalIfPresent(); + rightTabPage.goToAttachmentsTab(); + cy.wait(1000); // Allow tab content to load + + rightTabPage.getAttachmentsCount().then((initialCount) => { + cy.log(`Initial attachment count: ${initialCount}`); + + const timestamp = Date.now(); + const uniqueFileName = `test-attachment-${timestamp}.txt`; + + rightTabPage.uploadUniqueAttachment(uniqueFileName, timestamp); + + cy.contains("Successful").should("be.visible"); + cy.wait(2000); // Allow UI to update after upload + + rightTabPage.getAttachmentsCount().then((newCount) => { + cy.log(`New attachment count: ${newCount}`); + expect(newCount).to.be.greaterThan(initialCount); + }); + + rightTabPage.verifyAttachmentExists(uniqueFileName); + cy.screenshot("attachment-upload-complete"); + }); + }); + + // ============ Application Approval ============ + + it("Test approval workflow (confirm)", () => { + cy.reload(); // Refresh to ensure all changes are reflected before approval + detailsPage.dismissErrorModalIfPresent(); + detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); + }); + + // ============ Post-Approval Verification ============ + + it("Navigate back to applications list", () => { + cy.visit(`${Cypress.env("webapp.url")}GrantApplications`); + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + }); + + it("Verify application status is Approved", () => { + expect(submissionId, "Submission ID should be set").to.exist; + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId); + + cy.contains("tr", submissionId, { timeout: 20000 }).should( + "contain.text", + "Approved", + ); + }); + + // ============ Payment Request ============ + + it("Select approved application and submit payment request", () => { + listPage.selectRowByText(submissionId).clickPaymentButtonWithWait(); + listPage.waitForPaymentModalVisible(); + + // Description field has a max length of 40 chars + const paymentDescription = `AutoTest-${submissionId}`.slice(0, 40); + + // Modal uses divs (not a table) — target description input by id suffix + cy.get("#payment-modal input[id$='__Description']") + .should("be.visible") + .clear() + .type(paymentDescription); + + cy.contains("button", "Submit Payment Requests") + .should("be.visible") + .and("not.be.disabled") + .click(); + + cy.get("#payment-modal", { timeout: 20000 }).should("not.be.visible"); + cy.log(`✅ Payment request submitted: ${paymentDescription}`); + }); + + // ============ L1 Payment Approval (User 1) ============ + + it("Navigate to Payments tab and search for submission", () => { + navigateToPaymentsAndSearch(); + }); + + it("Select payment row and open Approve Payments modal", () => { + selectRowAndOpenApproveModal(); + }); + + it("L1 Approval - Validate modal details, enter note and approve", () => { + validateAndSubmitApproveModal("AutoApproval"); + }); + + // ============ L2 Payment Approval (User 2) ============ + + it("Logout user1 and login as user2 for L2 approval", () => { + cy.logout(); + loginIfNeeded({ + username: Cypress.env("test2username") as string, + password: Cypress.env("test2password") as string, + }); + }); + + it("Navigate to Payments tab and search for submission (L2)", () => { + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + navigateToPaymentsAndSearch(); + }); + + it("Select payment row and open Approve Payments modal (L2)", () => { + selectRowAndOpenApproveModal(); + }); + + it("L2 Approval - Validate modal details, enter note and approve", () => { + validateAndSubmitApproveModal("L2Approval"); + }); + + // ============ Post-L2 Validation ============ + + it("Validate payment status and approval dates on Payments table", () => { + const today = listPage.getTodayIsoLocal(); + + // Re-search to ensure the table reflects the latest state after L2 approval + cy.get("#search", { timeout: 20000 }) + .should("be.visible") + .clear() + .type(submissionId); + + cy.contains("tr", submissionId, { timeout: 20000 }).should(($row) => { + const text = $row.text(); + expect( + text.includes("Sent to Accounts Payable") || + text.includes("Submitted to CAS"), + 'Expected row to contain "Sent to Accounts Payable" or "Submitted to CAS"', + ).to.be.true; + }); + + // Validate date columns by resolving each column's index from its header title + ["Updated On", "L1 Approval Date", "L2 Approval Date"].forEach((header) => { + cy.get(".dt-scroll-head span.dt-column-title") + .contains(header) + .closest("th") + .invoke("index") + .then((colIndex) => { + cy.contains("tr", submissionId) + .find("td") + .eq(colIndex) + .should("contain.text", today); + }); + }); + }); + + // ============ Cleanup ============ + + it("Logout", () => { + cy.logout(); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/scripts/README.md b/applications/Unity.AutoUI/cypress/scripts/README.md new file mode 100644 index 0000000000..ce63c13c27 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/README.md @@ -0,0 +1,164 @@ +# CHEFS API Testing + +This directory contains Cypress tests for the CHEFS (Common Hosted Form Service) API. + +## Files + +- **chefs-api-submission.cy.ts**: Cypress test spec for CHEFS form submissions +- **chefs-api-config.json**: Environment configuration (baseURL, formId, versionId, headers) +- **chefs-submission-payload.json**: Form submission payload template + +## Setup + +### 1. Get a Valid CHEFS Authentication Token + +JWT tokens expire regularly (typically within hours/days). To get a fresh token: + +#### Option A: From Browser DevTools + +1. Navigate to CHEFS test environment: https://chefs-test.apps.silver.devops.gov.bc.ca +2. Login with your IDIR credentials +3. Open browser DevTools (F12) +4. Go to the **Network** tab +5. Submit a form or perform any API action +6. Find an API request to `/app/api/v1/forms/` +7. Click on the request and go to **Headers** +8. Copy the `Authorization` header value (starts with `Bearer eyJ...`) + +#### Option B: From Curl Command + +If you have a working curl command: + +```bash +curl 'https://chefs-test.apps.silver.devops.gov.bc.ca/...' \ + -H 'authorization: Bearer eyJhbGc...' +``` + +Copy the token from the authorization header. + +### 2. Update cypress.env.json + +Add or update the token in `cypress.env.json`: + +```json +{ + "CHEFS_AUTH_TOKEN": "Bearer *..." +} +``` + +**Note**: The `Bearer` prefix is optional - the test will handle both formats. + +### 3. Update Configuration (if needed) + +Edit `chefs-api-config.json` to match your environment: + +```json +{ + "environments": { + "test": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "your-form-id-here", + "versionId": "your-version-id-here" + } + } +} +``` + +## Running the Tests + +```bash +# Run all CHEFS API tests +npx cypress run --spec "cypress/scripts/chefs-api-submission.cy.ts" + +# Run in headed mode (see browser) +npx cypress open --spec "cypress/scripts/chefs-api-submission.cy.ts" +``` + +## Test Cases + +1. **Submit form via CHEFS API**: Submit a complete form with all fields +2. **Submit with custom data**: Override specific fields (applicant name, project title, etc.) +3. **Draft submission**: Submit as draft (not final) +4. **Retrieve submission**: Get submission details by ID +5. **Update submission files**: Add/update file attachments + +## Customizing the Payload + +Edit `chefs-submission-payload.json` to customize the form data: + +```json +{ + "submission": { + "data": { + "_ApplicantName": "Your Custom Name", + "_organizationName": "Your Organization", + "_projectTitle": "Your Project", + "_fundingRequest": 50000 + // ... other fields + } + } +} +``` + +## Troubleshooting + +### 401 Unauthorized Error + +**Cause**: Token is expired or invalid + +**Solution**: + +1. Get a fresh token (see Setup step 1) +2. Update `cypress.env.json` with the new token +3. Re-run the tests + +### 400 Bad Request Error + +**Cause**: Payload data doesn't match form schema + +**Solution**: + +1. Check form version ID in `chefs-api-config.json` +2. Verify payload structure matches the CHEFS form schema +3. Use browser DevTools to capture a valid submission payload + +### Test Skipped (No Token) + +**Cause**: `CHEFS_AUTH_TOKEN` not set in `cypress.env.json` + +**Solution**: Add token to `cypress.env.json` (see Setup step 2) + +## Security Note + +⚠️ **Never commit tokens to version control** + +- Add `cypress.env.json` to `.gitignore` +- Use environment variables in CI/CD pipelines +- Rotate tokens regularly +- Store tokens securely (e.g., password manager, CI secrets) + +## CI/CD Integration + +For automated testing in pipelines: + +```bash +# Set token as environment variable +export CYPRESS_CHEFS_AUTH_TOKEN="Bearer eyJhbGc..." + +# Run tests +npx cypress run --spec "cypress/scripts/chefs-api-submission.cy.ts" +``` + +Or use Cypress environment variable syntax in your CI config: + +```yaml +# Example GitHub Actions +env: + CYPRESS_CHEFS_AUTH_TOKEN: ${{ secrets.CHEFS_AUTH_TOKEN }} +``` + +```yaml +# Example GitLab CI +variables: + CYPRESS_CHEFS_AUTH_TOKEN: $CHEFS_AUTH_TOKEN # From CI/CD variables +``` diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json new file mode 100644 index 0000000000..0bf9000101 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json @@ -0,0 +1,23 @@ +{ + "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" + } + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts new file mode 100644 index 0000000000..74902a5dd9 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -0,0 +1,520 @@ +/// + +export {}; + +/** + * CHEFS Form Submission API Test + * + * This test submits a form to CHEFS (Common Hosted Form Service) via API + * All payloads and configuration are customizable via JSON files: + * - cypress/scripts/chefs-submission-payload.json - Form submission data + * - cypress/scripts/chefs-api-config.json - API configuration 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`); + + cy.get("#app > div > main > header > header > div > div.d-print-none", { + timeout, + }) + .should("exist") + .click(); + + cy.get( + "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", + { timeout }, + ) + .should("exist") + .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 Form Submission API", () => { + let apiConfig: ChefsApiConfig; + let submissionPayload: ChefsSubmissionPayload; + let environment: ChefsEnvironment; + let authToken: string; + let createdSubmissionId: 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"); + }); + }); + }); + + it("should submit form via CHEFS API", () => { + 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"); + + if (response.body.id) { + createdSubmissionId = response.body.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)"); + } + }); + }); + + it("should submit form with custom data overrides", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + const customPayload = JSON.parse(JSON.stringify(submissionPayload)); + + const timestamp = new Date().toISOString(); + customPayload.submission.data._ApplicantName = `AutoTest_${Date.now()}`; + customPayload.submission.data._projectTitle = `Automated Test Project ${timestamp}`; + customPayload.submission.data._ContactEmail = `autotest_${Date.now()}@example.com`; + customPayload.submission.data._totalProjectCost = 1000000; + customPayload.submission.data._fundingRequest = 750000; + + cy.log("Custom fields set:"); + cy.log(`- Applicant: ${customPayload.submission.data._ApplicantName}`); + cy.log(`- Project: ${customPayload.submission.data._projectTitle}`); + cy.log(`- Email: ${customPayload.submission.data._ContactEmail}`); + + 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: customPayload, + failOnStatusCode: false, + }).then((response) => { + 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"); + + if (response.body.id) { + cy.log(`✅ Custom submission created with ID: ${response.body.id}`); + } + }); + }); + + it("should handle draft submission", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + const draftPayload = JSON.parse(JSON.stringify(submissionPayload)); + + draftPayload.draft = true; + draftPayload.submission.state = "draft"; + draftPayload.submission.data._ApplicantName = `Draft_${Date.now()}`; + + cy.log("Submitting as DRAFT"); + + 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: draftPayload, + failOnStatusCode: false, + }).then((response) => { + 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"); + + if (response.body.draft !== undefined) { + expect(response.body.draft).to.be.true; + cy.log(`✅ Draft submission created with ID: ${response.body.id}`); + } + }); + }); + + it("should retrieve submission by ID", () => { + if (createdSubmissionId) { + const retrieveUrl = `${environment.baseURL}/app/api/v1/submissions/${createdSubmissionId}`; + + cy.request({ + method: "GET", + url: retrieveUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + }, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - CHEFS login credentials may be invalid"); + 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.eq(200); + + if (response.body.submission) { + expect(response.body.submission).to.have.property("id", createdSubmissionId); + cy.log(`✅ Retrieved submission: ${createdSubmissionId}`); + } else if (response.body.id) { + expect(response.body.id).to.eq(createdSubmissionId); + cy.log(`✅ Retrieved submission: ${createdSubmissionId}`); + } else { + cy.log("⚠️ Unexpected response structure - logging for debugging"); + cy.log(JSON.stringify(response.body, null, 2)); + } + }); + } else { + cy.log("⚠️ Skipping - No submission ID available"); + } + }); + + it("should submit form with file attachment", () => { + const filePath = `${Cypress.config("projectRoot")}/cypress/fixtures/test-attachment.txt`; + + cy.task("uploadChefsFile", { + baseURL: environment.baseURL, + authToken: authToken, + filePath: filePath, + }).then((fileRef: any) => { + cy.log(`✅ File uploaded: ${JSON.stringify(fileRef)}`); + + const payloadWithFile = JSON.parse(JSON.stringify(submissionPayload)); + payloadWithFile.submission.data.simplefile = Array.isArray(fileRef) + ? fileRef + : [fileRef]; + + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + + 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: payloadWithFile, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + 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"); + cy.log(`✅ Submission with attachment created: ${response.body.id}`); + }); + }); + }); + + it("should update submission payload data and save back to file", () => { + const updatedPayload = JSON.parse(JSON.stringify(submissionPayload)); + + updatedPayload.submission.data._ApplicantName = "UpdatedApplicant"; + updatedPayload.submission.data._projectTitle = "Updated Project Title"; + + cy.writeFile( + "cypress/scripts/chefs-submission-payload-updated.json", + updatedPayload, + ); + + cy.log("✅ Updated payload saved to chefs-submission-payload-updated.json"); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json new file mode 100644 index 0000000000..d7f02d0ff3 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json @@ -0,0 +1,193 @@ +{ + "draft": false, + "submission": { + "data": { + "next6": false, + "acceptanceOfEligibilityCriteria": true, + "previous6": false, + "next7": true, + "_ApplicantName": "UpdatedApplicant", + "_organizationName": { + "type": "name", + "sub_type": "entity_name", + "value": "DEC DESIGN", + "topic_source_id": "FM0036035", + "topic_type": "registration.registries.ca", + "credential_type": "registration.registries.ca", + "credential_id": "9ed8e4ee-0209-4548-90f5-7a6846bf3d5a", + "score": 63.596394 + }, + "_dateExtractBusinessName": "DEC DESIGN", + "_hiddenOrganizationName": "DEC DESIGN", + "_registeredBusinessNumber": "FM0036035", + "_hiddenOrganizationNumber": "FM0036035", + "_OrganizationName": "VelangTest", + "_OrganizationType": "CORPORATION", + "_OrgBookStatus": "HISTORICAL", + "_riskRanking": "MEDIUM", + "sector": { + "SectorCode": "21", + "SectorName": "Mining, quarrying, and oil and gas extraction", + "SubSectors": [ + { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + { + "SubSectorCode": "212", + "SubSectorName": "Mining and quarrying (except oil and gas)" + }, + { + "SubSectorCode": "213", + "SubSectorName": "Support activities for mining, and oil and gas extraction" + }, + { + "SubSectorCode": "0", + "SubSectorName": "Other" + } + ] + }, + "_hiddenSector": "Mining, quarrying, and oil and gas extraction", + "_hiddenSubsector": "Oil and gas extraction", + "_ContactName": "VelangTest", + "_ContactTitle": "VelangTest", + "_ContactEmail": "VelangTest@VelangTest.cVelangTest", + "_ContactPhoneNumberPrimary": "(987) 654-6545", + "_ContactPhoneNumberSecondary": "(321) 321-3213", + "_MailingAddressUnit": "", + "_MailingAddressStreet1": "11995 Haney Place", + "_MailingAddressStreet2": "", + "_MailingAddressCity": "Maple Ridge", + "_MailingAddressProvince": "British Columbia", + "_MailingAddressCountry": "Canada", + "_MailingAddressPostalCode": "V2X 6G2", + "_PhysicalAddressUnit": "", + "_PhysicalAddressStreet1": "11995 Haney Place", + "_PhysicalAddressStreet2": "", + "_PhysicalAddressCity": "Maple Ridge", + "_PhysicalAddressProvince": "British Columbia", + "_PhysicalAddressCountry": "Canada", + "_PhysicalAddressPostalCode": "V2X 6G2", + "_SigningAuthorityName": "VelangTest", + "_signatoryTitle1": "Chief Executive Officer (CEO)", + "_SigningAuthorityEmail": "VelangTest@VelangTest.com", + "_SigningAuthorityPhoneNumberPrimary": "(987) 564-3211", + "_SigningAuthorityPhoneNumberSecondary": "", + "simplefile": [], + "previous7": false, + "next8": true, + "_projectTitle": "Updated Project Title", + "pleaseBrieflyDescribeYourProject": "This project aims to develop sustainable resource extraction infrastructure in the Maple Ridge area, supporting local employment, improving community access to economic opportunities, and ensuring environmentally responsible operations in alignment with provincial standards.", + "projectLocationDetailed": { + "location": "Maple Ridge", + "place_name": "", + "community": "Maple Ridge", + "regional_district": "Metro Vancouver", + "economic_region": "Mainland/Southwest", + "rural_category": "Urban 1" + }, + "dataExtractEconomicRegion": "Mainland/Southwest", + "dataExtractRegionalDistrict": "Metro Vancouver", + "dataExtractCommunity": "Maple Ridge", + "dataExtractPlace": "", + "_EconomicRegionHidden": "Mainland/Southwest", + "_RegionalDistrictHidden": "Metro Vancouver", + "_CommunityHidden": "Maple Ridge", + "_placeHidden": "", + "_electoralDistrict": "Maple Ridge-Pitt Meadows", + "willOtherCommunitiesInBritishColumbiaDirectlyBenefit": "no", + "_Community1LocationHidden": "", + "_Community2LocationHidden": "", + "_Community3LocationHidden": "", + "hiddenComponentOtherCommunities": "", + "pleaseTellUsAboutTheCommunities": "Maple Ridge is a growing urban community in the Metro Vancouver Regional District with a population of approximately 80,000 residents. The community has an active local economy with significant opportunities in the resource sector, and residents would directly benefit from increased employment, improved local infrastructure, and expanded economic activity generated by this project.", + "whatCommunityNeedAreYouTryingToAddress": "The Maple Ridge community faces a need for sustainable economic diversification and skilled job creation in the resource sector. Current infrastructure limitations restrict the community's ability to attract investment and support local workers, resulting in residents commuting to other regions for employment opportunities in the oil and gas extraction industry.", + "whatAreTheIntendedOutcomesOfTheProject": "The project intends to: (1) Create a minimum of 25 full-time skilled positions for local residents; (2) Establish compliant and environmentally responsible extraction operations; (3) Contribute to the local tax base, supporting municipal services; (4) Develop partnerships with Indigenous communities and local suppliers; and (5) Deliver measurable reductions in operational emissions through modern equipment and practices.", + "keyProjectActivities": "1. Site assessment and environmental impact studies (April–May 2026); 2. Procurement of equipment and contractor engagement (May–June 2026); 3. Infrastructure construction and installation (June–September 2026); 4. Hiring and training of local workforce (August–October 2026); 5. Commissioning and operational launch (November 2026); 6. Ongoing monitoring, reporting, and community engagement (November–December 2026).", + "_indigenousOwned": "False", + "_forestryOrNon-Forestry": "NON_FORESTRY", + "_acquisition": "YES", + "previous1": false, + "next2": true, + "_ProjectStartDate": "2026-04-01T00:00:00-07:00", + "_ProjectEndDate": "2026-12-31T00:00:00-08:00", + "previous2": false, + "next3": true, + "source1FundingDescription": "Federal Government", + "source2FundingDescription": "Economic Trust", + "previous5": false, + "next5": true, + "iHaveReadTheAttestationAboveAndAgreetoAllTermsTherein": true, + "iAmAuthorizedToSubmitThisApplication": true, + "submit": true, + "previous4": false, + "lateEntry": false, + "subsector": { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + "_communityPopulation": 80000, + "_approxNumberOfEmployees": 85, + "_totalProjectCost": 800000, + "_fundingRequest": 500000, + "source1AmountOfFunding": 80000, + "source2AmountOfFunding": 98000 + }, + "metadata": { + "selectData": { + "_MailingAddressProvince": { + "label": "British Columbia" + }, + "_MailingAddressCountry": { + "label": "Canada" + }, + "_PhysicalAddressProvince": { + "label": "British Columbia" + }, + "_PhysicalAddressCountry": { + "label": "Canada" + }, + "_OrganizationType": { + "label": "Corporation" + }, + "_OrgBookStatus": { + "label": "Historical" + }, + "_riskRanking": { + "label": "Medium" + }, + "_signatoryTitle1": { + "label": "Chief Executive Officer (CEO)" + }, + "_electoralDistrict": { + "label": "Maple Ridge-Pitt Meadows" + }, + "_indigenousOwned": { + "label": "No" + }, + "_forestryOrNon-Forestry": { + "label": "Non-Forestry" + }, + "_acquisition": { + "label": "Yes" + }, + "source1FundingDescription": { + "label": "Federal Government" + }, + "source2FundingDescription": { + "label": "Economic Trust" + } + }, + "timezone": "America/Vancouver", + "offset": -480, + "origin": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "referrer": "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=f2f45aa7-62c5-49ca-8846-b214e02adb46", + "browserName": "Netscape", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "pathName": "/app/form/submit", + "onLine": true + }, + "state": "submitted", + "_vnote": "" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json new file mode 100644 index 0000000000..eb7a72973e --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json @@ -0,0 +1,193 @@ +{ + "draft": false, + "submission": { + "data": { + "next6": false, + "acceptanceOfEligibilityCriteria": true, + "previous6": false, + "next7": true, + "_ApplicantName": "VelangTest2", + "_organizationName": { + "type": "name", + "sub_type": "entity_name", + "value": "DEC DESIGN", + "topic_source_id": "FM0036035", + "topic_type": "registration.registries.ca", + "credential_type": "registration.registries.ca", + "credential_id": "9ed8e4ee-0209-4548-90f5-7a6846bf3d5a", + "score": 63.596394 + }, + "_dateExtractBusinessName": "DEC DESIGN", + "_hiddenOrganizationName": "DEC DESIGN", + "_registeredBusinessNumber": "FM0036035", + "_hiddenOrganizationNumber": "FM0036035", + "_OrganizationName": "VelangTest", + "_OrganizationType": "CORPORATION", + "_OrgBookStatus": "HISTORICAL", + "_riskRanking": "MEDIUM", + "sector": { + "SectorCode": "21", + "SectorName": "Mining, quarrying, and oil and gas extraction", + "SubSectors": [ + { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + { + "SubSectorCode": "212", + "SubSectorName": "Mining and quarrying (except oil and gas)" + }, + { + "SubSectorCode": "213", + "SubSectorName": "Support activities for mining, and oil and gas extraction" + }, + { + "SubSectorCode": "0", + "SubSectorName": "Other" + } + ] + }, + "_hiddenSector": "Mining, quarrying, and oil and gas extraction", + "_hiddenSubsector": "Oil and gas extraction", + "_ContactName": "VelangTest", + "_ContactTitle": "VelangTest", + "_ContactEmail": "VelangTest@VelangTest.cVelangTest", + "_ContactPhoneNumberPrimary": "(987) 654-6545", + "_ContactPhoneNumberSecondary": "(321) 321-3213", + "_MailingAddressUnit": "", + "_MailingAddressStreet1": "11995 Haney Place", + "_MailingAddressStreet2": "", + "_MailingAddressCity": "Maple Ridge", + "_MailingAddressProvince": "British Columbia", + "_MailingAddressCountry": "Canada", + "_MailingAddressPostalCode": "V2X 6G2", + "_PhysicalAddressUnit": "", + "_PhysicalAddressStreet1": "11995 Haney Place", + "_PhysicalAddressStreet2": "", + "_PhysicalAddressCity": "Maple Ridge", + "_PhysicalAddressProvince": "British Columbia", + "_PhysicalAddressCountry": "Canada", + "_PhysicalAddressPostalCode": "V2X 6G2", + "_SigningAuthorityName": "VelangTest", + "_signatoryTitle1": "Chief Executive Officer (CEO)", + "_SigningAuthorityEmail": "VelangTest@VelangTest.com", + "_SigningAuthorityPhoneNumberPrimary": "(987) 564-3211", + "_SigningAuthorityPhoneNumberSecondary": "", + "simplefile": [], + "previous7": false, + "next8": true, + "_projectTitle": "Maple Ridge Community Resource Development Initiative", + "pleaseBrieflyDescribeYourProject": "This project aims to develop sustainable resource extraction infrastructure in the Maple Ridge area, supporting local employment, improving community access to economic opportunities, and ensuring environmentally responsible operations in alignment with provincial standards.", + "projectLocationDetailed": { + "location": "Maple Ridge", + "place_name": "", + "community": "Maple Ridge", + "regional_district": "Metro Vancouver", + "economic_region": "Mainland/Southwest", + "rural_category": "Urban 1" + }, + "dataExtractEconomicRegion": "Mainland/Southwest", + "dataExtractRegionalDistrict": "Metro Vancouver", + "dataExtractCommunity": "Maple Ridge", + "dataExtractPlace": "", + "_EconomicRegionHidden": "Mainland/Southwest", + "_RegionalDistrictHidden": "Metro Vancouver", + "_CommunityHidden": "Maple Ridge", + "_placeHidden": "", + "_electoralDistrict": "Maple Ridge-Pitt Meadows", + "willOtherCommunitiesInBritishColumbiaDirectlyBenefit": "no", + "_Community1LocationHidden": "", + "_Community2LocationHidden": "", + "_Community3LocationHidden": "", + "hiddenComponentOtherCommunities": "", + "pleaseTellUsAboutTheCommunities": "Maple Ridge is a growing urban community in the Metro Vancouver Regional District with a population of approximately 80,000 residents. The community has an active local economy with significant opportunities in the resource sector, and residents would directly benefit from increased employment, improved local infrastructure, and expanded economic activity generated by this project.", + "whatCommunityNeedAreYouTryingToAddress": "The Maple Ridge community faces a need for sustainable economic diversification and skilled job creation in the resource sector. Current infrastructure limitations restrict the community's ability to attract investment and support local workers, resulting in residents commuting to other regions for employment opportunities in the oil and gas extraction industry.", + "whatAreTheIntendedOutcomesOfTheProject": "The project intends to: (1) Create a minimum of 25 full-time skilled positions for local residents; (2) Establish compliant and environmentally responsible extraction operations; (3) Contribute to the local tax base, supporting municipal services; (4) Develop partnerships with Indigenous communities and local suppliers; and (5) Deliver measurable reductions in operational emissions through modern equipment and practices.", + "keyProjectActivities": "1. Site assessment and environmental impact studies (April–May 2026); 2. Procurement of equipment and contractor engagement (May–June 2026); 3. Infrastructure construction and installation (June–September 2026); 4. Hiring and training of local workforce (August–October 2026); 5. Commissioning and operational launch (November 2026); 6. Ongoing monitoring, reporting, and community engagement (November–December 2026).", + "_indigenousOwned": "False", + "_forestryOrNon-Forestry": "NON_FORESTRY", + "_acquisition": "YES", + "previous1": false, + "next2": true, + "_ProjectStartDate": "2026-04-01T00:00:00-07:00", + "_ProjectEndDate": "2026-12-31T00:00:00-08:00", + "previous2": false, + "next3": true, + "source1FundingDescription": "Federal Government", + "source2FundingDescription": "Economic Trust", + "previous5": false, + "next5": true, + "iHaveReadTheAttestationAboveAndAgreetoAllTermsTherein": true, + "iAmAuthorizedToSubmitThisApplication": true, + "submit": true, + "previous4": false, + "lateEntry": false, + "subsector": { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + "_communityPopulation": 80000, + "_approxNumberOfEmployees": 85, + "_totalProjectCost": 800000, + "_fundingRequest": 500000, + "source1AmountOfFunding": 80000, + "source2AmountOfFunding": 98000 + }, + "metadata": { + "selectData": { + "_MailingAddressProvince": { + "label": "British Columbia" + }, + "_MailingAddressCountry": { + "label": "Canada" + }, + "_PhysicalAddressProvince": { + "label": "British Columbia" + }, + "_PhysicalAddressCountry": { + "label": "Canada" + }, + "_OrganizationType": { + "label": "Corporation" + }, + "_OrgBookStatus": { + "label": "Historical" + }, + "_riskRanking": { + "label": "Medium" + }, + "_signatoryTitle1": { + "label": "Chief Executive Officer (CEO)" + }, + "_electoralDistrict": { + "label": "Maple Ridge-Pitt Meadows" + }, + "_indigenousOwned": { + "label": "No" + }, + "_forestryOrNon-Forestry": { + "label": "Non-Forestry" + }, + "_acquisition": { + "label": "Yes" + }, + "source1FundingDescription": { + "label": "Federal Government" + }, + "source2FundingDescription": { + "label": "Economic Trust" + } + }, + "timezone": "America/Vancouver", + "offset": -480, + "origin": "", + "referrer": "", + "browserName": "Netscape", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "pathName": "/app/form/submit", + "onLine": true + }, + "state": "submitted", + "_vnote": "" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/support/commands.ts b/applications/Unity.AutoUI/cypress/support/commands.ts index 686e928f52..7216c27660 100644 --- a/applications/Unity.AutoUI/cypress/support/commands.ts +++ b/applications/Unity.AutoUI/cypress/support/commands.ts @@ -62,9 +62,9 @@ Cypress.Commands.add("getSubmissionDetail", (key: string) => { return cy .fixture<{ submissionDetails: SubmissionDetail[] }>("submissions.json") .then(({ submissionDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = submissionDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -86,9 +86,9 @@ Cypress.Commands.add("getMetabaseDetail", (key: string) => { return cy .fixture<{ metabaseDetails: MetabaseDetail[] }>("metabase.json") .then(({ metabaseDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = metabaseDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -125,9 +125,9 @@ Cypress.Commands.add("getChefsDetail", (key: string) => { return cy .fixture<{ chefsDetails: chefsDetail[] }>("chefs.json") .then(({ chefsDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = chefsDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -196,3 +196,163 @@ Cypress.Commands.add("clearBrowserCache", () => { }); }); }); + +// ============ Dynamic Submission Fetching ============ + +// Use interfaces from index.d.ts - only define API response wrapper here +interface GrantApplicationResponse { + items: GrantApplication[]; + totalCount: number; +} + +/** + * Fetches a dynamic submission ID (referenceNo) from the API after login. + * Uses session cookies automatically from Cypress. + * Results are sorted by submissionDate descending (latest first) by default. + * + * @param options - Optional filters for selecting submissions + * @returns Chainable containing the referenceNo (e.g., "209BD469") + * + * @example + * // Get latest submission from "Data Seeder" category + * cy.fetchDynamicSubmission({ categoryFilter: 'Data Seeder' }).then((id) => { ... }) + * + * @example + * // Get latest "Submitted" submission + * cy.fetchDynamicSubmission({ statusFilter: ['Submitted'] }).then((id) => { ... }) + * + * @example + * // Get second-latest submission from specific category + * cy.fetchDynamicSubmission({ categoryFilter: 'Data Seeder', index: 1 }).then((id) => { ... }) + * + * Available status values: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' + */ +function fetchGrantApplications(): Cypress.Chainable { + const apiUrl = `${Cypress.env("webapp.url")}api/app/grant-application`; + return cy.getCookie("XSRF-TOKEN").then((xsrfCookie) => { + return cy + .request({ + method: "GET", + url: apiUrl, + qs: { submittedFromDate: "", submittedToDate: "" }, + headers: { + Accept: "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + RequestVerificationToken: xsrfCookie?.value || "", + }, + failOnStatusCode: false, + }) + .then((response) => { + if (response.status !== 200) { + throw new Error( + `API request failed with status ${response.status}: ${JSON.stringify(response.body)}` + ); + } + const data = response.body as GrantApplicationResponse; + Cypress.log({ name: "fetch", message: `📋 Fetched ${data.items?.length || 0} applications` }); + return data.items || []; + }); + }); +} + +Cypress.Commands.add( + "fetchDynamicSubmission", + (options: FetchSubmissionOptions = {}) => { + return fetchGrantApplications().then((allApplications) => { + let applications = allApplications; + + Cypress.log({ name: "fetch", message: `📋 Fetched ${applications.length} applications from API` }); + + // Filter by category if specified (e.g., 'Data Seeder') + if (options.categoryFilter) { + applications = applications.filter((app) => + app.category === options.categoryFilter + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with category: ${options.categoryFilter}`, + }); + } + + // Filter by status if specified (e.g., 'Submitted', 'Under Assessment', 'Approved') + if (options.statusFilter && options.statusFilter.length > 0) { + applications = applications.filter((app) => + options.statusFilter!.includes(app.status) + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with status: ${options.statusFilter.join(", ")}`, + }); + } + + // Filter by max age if specified + if (options.maxAge) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - options.maxAge); + applications = applications.filter((app) => { + const submissionDate = new Date(app.submissionDate); + return submissionDate >= cutoffDate; + }); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications within ${options.maxAge} days`, + }); + } + + if (applications.length === 0) { + throw new Error( + "No applications found matching the specified criteria" + ); + } + + // Sort applications (default: by submissionDate descending for latest first) + const sortBy = options.sortBy || 'submissionDate'; + const sortOrder = options.sortOrder || 'desc'; + applications.sort((a, b) => { + let aVal: number | string; + let bVal: number | string; + + if (sortBy === 'submissionDate') { + aVal = new Date(a.submissionDate).getTime(); + bVal = new Date(b.submissionDate).getTime(); + } else { + aVal = a[sortBy] as number; + bVal = b[sortBy] as number; + } + + if (sortOrder === 'desc') { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + // Get the submission at the specified index (default: 0 = first/latest) + const index = options.index || 0; + if (index >= applications.length) { + throw new Error( + `Index ${index} out of range. Only ${applications.length} applications available.` + ); + } + + const selectedApp = applications[index]; + Cypress.log({ + name: "selected", + message: `✅ Selected submission: ${selectedApp.referenceNo} (Status: ${selectedApp.status}, Category: ${selectedApp.category})`, + }); + + return selectedApp.referenceNo; + }); + } +); + +/** + * Fetches all available submissions from the API. + * Useful for selecting a specific submission based on custom criteria. + * + * @returns Chainable containing array of grant applications + */ +Cypress.Commands.add("fetchAllSubmissions", () => { + return fetchGrantApplications(); +}); diff --git a/applications/Unity.AutoUI/cypress/support/e2e.ts b/applications/Unity.AutoUI/cypress/support/e2e.ts index 860eac1b25..0b9acad7b3 100644 --- a/applications/Unity.AutoUI/cypress/support/e2e.ts +++ b/applications/Unity.AutoUI/cypress/support/e2e.ts @@ -8,12 +8,24 @@ import '../support/commands' -// Ignore ResizeObserver loop errors - these are benign browser notifications -// that occur when ResizeObserver callbacks don't complete in a single animation frame +// Ignore common errors that shouldn't fail tests Cypress.on('uncaught:exception', (err) => { + // ResizeObserver loop errors - benign browser notifications if (err.message.includes('ResizeObserver loop')) { return false } - // Return true to fail the test for other errors + // Network errors that can occur during navigation + if (err.message.includes('Network Error') || err.message.includes('net::ERR')) { + return false + } + // Script errors from third-party resources + if (err.message.includes('Script error')) { + return false + } + // Chunk loading errors + if (err.message.includes('Loading chunk') || err.message.includes('ChunkLoadError')) { + return false + } + // Return true to fail tests on unexpected uncaught exceptions return true }) diff --git a/applications/Unity.AutoUI/cypress/support/index.d.ts b/applications/Unity.AutoUI/cypress/support/index.d.ts index ad8fa26a5e..979f84c4a5 100644 --- a/applications/Unity.AutoUI/cypress/support/index.d.ts +++ b/applications/Unity.AutoUI/cypress/support/index.d.ts @@ -6,17 +6,107 @@ // https://on.cypress.io/configuration // *********************************************************** -declare namespace Cypress { - interface Chainable { - login(): void; // Custom command to login to Unity - logout(): void; // Custom command to log out of Unity - getSubmissionDetail(key: string): Chainable; // Custom command to get submission details by key for the current environment. - getMetabaseDetail(key: string): Chainable; // Custom command to get metabase details by key for the current environment. - metabaseLogin(): Chainable; // Custom command to login to Metabase - getChefsDetail(key: string): Chainable; // Custom command to get chefs details by key for the current environment. - chefsLogin(): Chainable; // Custom command to login to Chefs - chefsLogout(): Chainable; // Custom command to log out of Chefs - clearSessionStorage(): Chainable; // Custom command to clear session storage. - clearBrowserCache(): Chainable; // Custom command to clear browser cache. +/** + * Grant Application interface matching actual API response + * from /api/app/grant-application endpoint + */ +interface GrantApplication { + id: string; + /** The submission reference number displayed in UI (e.g., "209BD469") */ + referenceNo: string; + /** Application status (e.g., "Submitted", "Approved", "Under Assessment", "Closed", "Deferred") */ + status: string; + /** ISO date string of submission */ + submissionDate: string; + /** Project name */ + projectName: string; + /** Organization name */ + organizationName: string; + /** Requested funding amount */ + requestedAmount: number; + /** Approved funding amount */ + approvedAmount: number; + /** Category/program name */ + category: string; + /** City */ + city: string; + /** Additional fields from API */ + [key: string]: unknown; +} + +/** + * Options for fetching dynamic submissions + */ +interface FetchSubmissionOptions { + /** Filter by status values (e.g., ['Submitted', 'Under Assessment']) */ + statusFilter?: string[]; + /** Filter by category/program name (e.g., 'Data Seeder') */ + categoryFilter?: string; + /** Max age in days (default: no limit) */ + maxAge?: number; + /** Sort by field (default: 'submissionDate') */ + sortBy?: 'submissionDate' | 'requestedAmount' | 'approvedAmount'; + /** Sort order (default: 'desc' for latest first) */ + sortOrder?: 'asc' | 'desc'; + /** Which submission to return after sorting (default: 0 = first/latest) */ + index?: number; +} + +declare namespace Cypress { + interface Chainable { + /** Custom command to login to Unity */ + login(): void; + + /** Custom command to log out of Unity */ + logout(): void; + + /** Custom command to get submission details by key for the current environment */ + getSubmissionDetail(key: string): Chainable; + + /** Custom command to get metabase details by key for the current environment */ + getMetabaseDetail(key: string): Chainable; + + /** Custom command to login to Metabase */ + metabaseLogin(): Chainable; + + /** Custom command to get chefs details by key for the current environment */ + getChefsDetail(key: string): Chainable; + + /** Custom command to login to Chefs */ + chefsLogin(): Chainable; + + /** Custom command to log out of Chefs */ + chefsLogout(): Chainable; + + /** Custom command to clear session storage */ + clearSessionStorage(): Chainable; + + /** Custom command to clear browser cache */ + clearBrowserCache(): Chainable; + + /** + * Fetches a dynamic submission ID from the API after login. + * Uses session cookies automatically from Cypress. + * + * @param options - Optional filters for selecting submissions + * @returns Chainable containing the confirmation ID + * + * @example + * // Get first available submission + * cy.fetchDynamicSubmission().then((id) => { ... }) + * + * @example + * // Get second submission with specific status + * cy.fetchDynamicSubmission({ statusFilter: ['SUBMITTED'], index: 1 }).then((id) => { ... }) + */ + fetchDynamicSubmission(options?: FetchSubmissionOptions): Chainable; + + /** + * Fetches all available submissions from the API. + * Useful for selecting a specific submission based on custom criteria. + * + * @returns Chainable containing array of grant applications + */ + fetchAllSubmissions(): Chainable; } -} \ No newline at end of file +} diff --git a/applications/Unity.AutoUI/package.json b/applications/Unity.AutoUI/package.json index 380421fbe4..d275c5ea12 100644 --- a/applications/Unity.AutoUI/package.json +++ b/applications/Unity.AutoUI/package.json @@ -1,9 +1,20 @@ { + "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" + }, "dependencies": { "form-data": "^4.0.5", "typescript": "^5.8.3" }, "devDependencies": { - "cypress": "^15.8.1" + "@types/node": "^25.4.0", + "cypress": "15.12.0" } -} +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/tsconfig.json b/applications/Unity.AutoUI/tsconfig.json index 89a0c8e415..75780a90bf 100644 --- a/applications/Unity.AutoUI/tsconfig.json +++ b/applications/Unity.AutoUI/tsconfig.json @@ -106,5 +106,11 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, // Included or excluded files or folders... https://www.typescriptlang.org/tsconfig - "include": ["cypress/support/**/*.ts", "cypress/e2e/**/*.ts", "cypress/pages/**/*.ts"] + "include": [ + "cypress/support/**/*.ts", + "cypress/e2e/**/*.ts", + "cypress/pages/**/*.ts", + "cypress/regression/**/*.ts", + "cypress/scripts/**/*.ts" + ] } diff --git a/applications/Unity.GrantManager/.env.example b/applications/Unity.GrantManager/.env.example index c2094e4c05..c937e86596 100644 --- a/applications/Unity.GrantManager/.env.example +++ b/applications/Unity.GrantManager/.env.example @@ -56,6 +56,8 @@ AuthServer__OidcSignoutCallback="http://localhost:44342/signout-callback-oidc" ##S3__AssessmentS3Folder="Unity/Adjudication" ##S3__DisallowedFileTypes="[ "exe" , "sh" , "ksh" , "bat" , "cmd" ]" ##S3__MaxFileSize="25" +##S3__EmailAttachmentMaxFileSize="20" +##S3__EmailAttachmentsTotalMaxFileSize="25" ## RABBIT MQ UNITY_RABBIT_MQ_HOST="rabbitmq" UNITY_RABBIT_MQ_PORT="5672" diff --git a/applications/Unity.GrantManager/.github/AGENTS.md b/applications/Unity.GrantManager/.github/AGENTS.md new file mode 100644 index 0000000000..649e86322f --- /dev/null +++ b/applications/Unity.GrantManager/.github/AGENTS.md @@ -0,0 +1,24 @@ +# ABP Workflow Agents + +This folder contains reusable Copilot agent definitions tailored to Unity Grant Manager ABP workflows. + +## Agent Catalog + +- `feature-planner.agent.md` - Breaks a feature request into ABP-aligned implementation steps. +- `ddd-modeler.agent.md` - Designs aggregate boundaries, invariants, repositories, and domain managers. +- `application-service-designer.agent.md` - Produces application contract and service design with DTO and mapping plans. +- `efcore-migration-planner.agent.md` - Plans host vs tenant EF Core changes and migration steps. +- `permissions-localization-auditor.agent.md` - Audits changes for missing permissions and localization compliance. +- `test-strategy.agent.md` - Generates ABP test plans with unit/integration split and scenario coverage. +- `test-triage.agent.md` - Diagnoses failing tests and proposes minimal-risk fix sequences. +- `pr-readiness.agent.md` - Runs a final ABP policy and quality gate before PR creation. + +## Usage + +Pick the agent that matches your workflow stage and provide: + +1. Feature or bug context. +2. Target module(s) and files. +3. Constraints (tenant scope, security, deadline, non-functional requirements). + +Each agent enforces ABP layering, AutoMapper usage, localization, and test conventions from repository instructions and skills. \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/agents/application-service-designer.agent.md b/applications/Unity.GrantManager/.github/agents/application-service-designer.agent.md new file mode 100644 index 0000000000..0126a8087a --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/application-service-designer.agent.md @@ -0,0 +1,43 @@ +--- +name: application-service-designer +description: Designs application contracts, DTOs, authorization, and AutoMapper mapping plans. +--- + +# ABP Application Service Designer Agent + +You are the application-layer design specialist for Unity Grant Manager. + +## Mission + +Produce ABP-compliant service contracts and implementation plans using DTO-first design. + +## Inputs + +- Use cases and API behavior. +- Existing service interfaces and DTOs. +- Target module and permissions. + +## Process + +1. Propose or update `I*AppService` method signatures. +2. Define DTOs per method intent (create, update, get, list). +3. Identify authorization requirements and permission constants. +4. Define AutoMapper profile changes. +5. Define validation and business-exception boundaries. + +## Output Format + +1. Contract changes. +2. DTO matrix. +3. Authorization matrix. +4. Mapping profile changes. +5. Service implementation checklist. +6. Test targets. + +## Guardrails + +- Apply `.github/skills/unity-application-layer/SKILL.md`. +- Follow `.github/instructions/csharp.instructions.md`. +- Methods must be async and end with `Async`. +- Accept/return DTOs only, never entities. +- Use AutoMapper with `ObjectMapper.Map<>()`, never Mapperly. diff --git a/applications/Unity.GrantManager/.github/agents/ddd-modeler.agent.md b/applications/Unity.GrantManager/.github/agents/ddd-modeler.agent.md new file mode 100644 index 0000000000..5e24457414 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/ddd-modeler.agent.md @@ -0,0 +1,44 @@ +--- +name: ddd-modeler +description: Designs and reviews ABP DDD models, aggregates, repositories, and domain managers. +--- + +# ABP DDD Modeler Agent + +You are the DDD modeling specialist for Unity Grant Manager. + +## Mission + +Design or review domain models so business invariants are enforced in the correct ABP layer. + +## Inputs + +- Business rules and scenarios. +- Existing entities and repository interfaces. +- Target module. + +## Process + +1. Define aggregate boundaries and ownership rules. +2. Identify entity/value object responsibilities. +3. Propose behavior methods that enforce invariants. +4. Define repository contract additions only for aggregate roots. +5. Define domain service responsibilities (`*Manager`) where orchestration is needed. +6. Propose business error codes and exception points. + +## Output Format + +1. Aggregate model proposal. +2. Invariants and rule enforcement table. +3. Repository contract changes. +4. Domain manager methods. +5. Error code list. +6. Anti-pattern checks. + +## Guardrails + +- Apply `.github/skills/unity-domain-driven-design/SKILL.md`. +- Follow `.github/instructions/csharp.instructions.md`. +- Do not generate GUIDs in entity constructors. +- Reference external aggregates by Id only. +- Keep app-service logic out of the domain model design. diff --git a/applications/Unity.GrantManager/.github/agents/efcore-migration-planner.agent.md b/applications/Unity.GrantManager/.github/agents/efcore-migration-planner.agent.md new file mode 100644 index 0000000000..6605be3cec --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/efcore-migration-planner.agent.md @@ -0,0 +1,43 @@ +--- +name: efcore-migration-planner +description: Plans EF Core model updates and host versus tenant migrations safely. +--- + +# ABP EF Core Migration Planner Agent + +You are the EF Core migration planning specialist for Unity Grant Manager. + +## Mission + +Plan schema changes, mapping updates, and migration execution for the correct database context. + +## Inputs + +- Proposed entity/model changes. +- Whether data is host-wide or tenant-scoped. +- Existing migrations and repository code. + +## Process + +1. Classify each change as host, tenant, or both. +2. Propose `ModelBuilder` mapping updates. +3. Verify repository impact and query behavior. +4. Produce migration commands and ordering. +5. Identify rollback and data backfill considerations. + +## Output Format + +1. Context classification. +2. Mapping change checklist. +3. Migration command plan. +4. Data safety notes. +5. Repository update checklist. +6. Validation tests. + +## Guardrails + +- Apply `.github/skills/unity-ef-core/SKILL.md`. +- Follow `.github/instructions/efcore.instructions.md`. +- Always call `ConfigureByConvention()` for mapped entities. +- Do not use `includeAllEntities: true` with default repositories. +- Always specify context for migration commands. diff --git a/applications/Unity.GrantManager/.github/agents/feature-planner.agent.md b/applications/Unity.GrantManager/.github/agents/feature-planner.agent.md new file mode 100644 index 0000000000..adb12769d6 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/feature-planner.agent.md @@ -0,0 +1,52 @@ +--- +name: feature-planner +description: Plans feature implementation across Domain, Application, EF Core, Web, and tests. +tools: ['fetch', 'githubRepo', 'problems', 'usages', 'search', 'todos', 'runSubagent'] +--- + +# ABP Feature Planner Agent + +You are the feature planning specialist for Unity Grant Manager. + +## Mission + +Convert a feature request into an implementation plan that respects ABP modular layering and delivery flow. + +## Inputs + +- Feature or bug statement. +- Acceptance criteria. +- Target module(s). +- Any constraints (timeline, migration risk, tenant scope, security requirements). + +## Process + +1. Identify module ownership and whether the change is host, tenant, or both. +2. Split work by layer: + - Domain.Shared + - Domain + - Application.Contracts + - Application + - EntityFrameworkCore + - HttpApi/Web + - Tests +3. List dependencies and ordering constraints. +4. Flag cross-module impacts and permission/localization requirements. + +## Output Format + +Return sections in this order: + +1. Scope summary. +2. Layer-by-layer implementation tasks. +3. Migration and data impact. +4. Test plan summary. +5. Risks and mitigations. +6. Definition of done checklist. + +## Guardrails + +- Enforce module dependency direction from `.github/skills/unity-module-structure/SKILL.md`. +- Enforce ABP app/domain rules from `.github/instructions/csharp.instructions.md`. +- Do not use Mapperly. Use AutoMapper. +- Do not place business rules in controllers or app services. diff --git a/applications/Unity.GrantManager/.github/agents/permissions-localization-auditor.agent.md b/applications/Unity.GrantManager/.github/agents/permissions-localization-auditor.agent.md new file mode 100644 index 0000000000..7d8738b3a8 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/permissions-localization-auditor.agent.md @@ -0,0 +1,40 @@ +--- +name: permissions-localization-auditor +description: Audits ABP changes for permission coverage, localization correctness, and policy compliance. +--- + +# ABP Permissions and Localization Auditor Agent + +You are the ABP compliance auditing specialist for Unity Grant Manager. + +## Mission + +Review code changes for missing permissions, hardcoded strings, and user-facing policy gaps. + +## Inputs + +- Diff or list of changed files. +- Affected user flows and roles. + +## Process + +1. Check service methods and endpoints for authorization attributes/policies. +2. Verify permission constants and definition provider coverage. +3. Scan for hardcoded user-facing text. +4. Verify localization key usage and resource updates. +5. Identify likely regressions and required tests. + +## Output Format + +1. Findings by severity. +2. Missing permissions list. +3. Localization findings list. +4. Required code changes. +5. Validation checklist. + +## Guardrails + +- Follow `.github/copilot-instructions.md` and `.github/instructions/csharp.instructions.md`. +- All user-facing text must be localized. +- Permissions must be defined in Application.Contracts permission providers. +- Do not propose hardcoded strings in services, controllers, or UI code. diff --git a/applications/Unity.GrantManager/.github/agents/pr-readiness.agent.md b/applications/Unity.GrantManager/.github/agents/pr-readiness.agent.md new file mode 100644 index 0000000000..88bd2466a0 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/pr-readiness.agent.md @@ -0,0 +1,41 @@ +--- +name: pr-readiness +description: Performs pre-PR quality gate checks for build, tests, layering, and policy compliance. +--- + +# ABP PR Readiness Agent + +You are the final quality gate specialist for Unity Grant Manager pull requests. + +## Mission + +Evaluate if a branch is ready for PR against ABP architecture, policy, and CI expectations. + +## Inputs + +- Branch diff. +- Build and test status. +- Target branch. + +## Process + +1. Verify branch policy and PR source/target compatibility. +2. Check layering boundaries and module dependency direction. +3. Check mapping, DTO boundaries, localization, and permissions. +4. Check migration context correctness when EF changes exist. +5. Confirm test coverage and CI command readiness. + +## Output Format + +1. Go/No-go recommendation. +2. Blocking issues. +3. Non-blocking improvements. +4. Required validation commands. +5. PR description checklist. + +## Guardrails + +- Follow `.github/copilot-instructions.md`. +- Require `dotnet build Unity.GrantManager.sln --no-restore` and `dotnet test Unity.GrantManager.sln --no-build` readiness. +- Enforce ABP module layering rules from `.github/skills/unity-module-structure/SKILL.md`. +- Enforce AutoMapper, localization, and permissions conventions. diff --git a/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md b/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md new file mode 100644 index 0000000000..bd78117a88 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md @@ -0,0 +1,262 @@ +--- +name: pr-readiness-deep +description: Deep PR quality gate that actively scans and fixes SonarQube issues, CodeQL vulnerabilities, and ABP architecture violations. +--- + +# PR Readiness Agent (Deep Scan) + +Final quality gate for Unity Grant Manager PRs with active issue detection and remediation. + +## Inputs +- Branch diff, build/test status, target branch + +## Core Capabilities +- **Active SonarQube scanning**: Use `sonarqube_analyze_file` to scan modified files +- **Security issue detection**: Use `sonarqube_list_potential_security_issues` for security hotspots +- **Automatic fixes**: Apply fixes for detected SonarQube and CodeQL issues +- **ABP validation**: Verify architecture compliance +- **Cypress E2E testing**: Run frontend integration tests from Unity.AutoUI project + +## Quality Checks Workflow + +### Step 1: Analyze Modified Files +For each changed file in the PR: +1. **Run SonarQube analysis**: `sonarqube_analyze_file` on the file +2. **Parse results**: Identify critical/blocker/major issues +3. **Fix issues automatically**: Apply fixes for common patterns +4. **Re-scan**: Verify fixes resolved the issues + +### Step 2: Security Scan +1. **List security issues**: `sonarqube_list_potential_security_issues` +2. **Check CodeQL alerts**: Review GitHub Security tab findings +3. **Prioritize**: Critical > High > Medium +4. **Fix**: Apply security patches + +### Step 3: ABP Architecture +- Layer boundaries (Domain → Application → Web) +- Repository/DTO/AutoMapper conventions +- Permissions and localization keys +- EF migrations (if schema changes) + +### Step 4: Build & Tests +```bash +# Backend build and unit tests +dotnet build Unity.GrantManager.sln --no-restore +dotnet test Unity.GrantManager.sln --no-build + +# Frontend Cypress E2E tests +cd ../Unity.AutoUI +npm install +npx cypress run +``` + +### Step 5: Cypress E2E Testing +1. **Navigate to Cypress project**: `cd applications/Unity.AutoUI` +2. **Run tests**: Use `npx cypress run` for headless, `npx cypress open` for interactive +3. **Parse results**: Check for failed tests, screenshots, videos +4. **Report**: Include test pass/fail status in output + +## SonarQube Issues to Auto-Fix + +**Critical/Blocker (Must Fix)**: +- SQL injection → Convert to EF LINQ +- Missing `[Authorize]` → Add authorization attributes +- Exposing domain entities → Convert to DTO pattern +- Hardcoded credentials → Move to configuration +- Resource leaks → Add proper disposal + +**Major (Should Fix)**: +- High complexity → Extract methods +- Code duplication → Create shared utilities +- Empty catch blocks → Add proper logging + +**Process**: +1. Use `sonarqube_analyze_file` on each modified file +2. Parse issue severity, rule, and location +3. Apply appropriate fix pattern (see Common Fixes below) +4. Re-analyze to confirm resolution + +### CodeQL Security (Check GitHub Security Tab) +**Must Fix (Critical/High)**: +- SQL injection +- Path traversal → Validate file paths +- Missing authorization +- Logging sensitive data +- Hardcoded secrets + +**After fixes**: Verify alerts cleared in GitHub Security tab + +## Action Mode + +When invoked: +1. **Scan all changed files** using `sonarqube_analyze_file` +2. **List security issues** using `sonarqube_list_potential_security_issues` +3. **Apply fixes** for detected issues using patterns below +4. **Run backend tests**: `dotnet test Unity.GrantManager.sln --no-build` +5. **Run Cypress E2E tests**: Navigate to Unity.AutoUI and run `npx cypress run` +6. **Validate**: Re-run analysis to confirm resolution +7. **Report**: Summary of issues found/fixed and test results + +## Output + +**After Scanning & Fixing**: +1. **Summary Report**: + - Files analyzed: X + - Issues found: Y (Critical: Z, High: W) + - Issues fixed: N + - Remaining issues: M + - Backend tests: X passed, Y failed + - Cypress E2E tests: X passed, Y failed (with links to screenshots/videos if failures) +2. **Go/No-Go Decision**: + - ✅ GO: No critical/blocker issues remain, all tests pass + - ❌ NO-GO: Critical issues, test failures, or security vulnerabilities require intervention + - ⚠️ CONDITIONAL: Minor issues present but can merge with follow-up tasks +3. **Detailed Findings**: + - Fixed automatically: List with file:line + - Manual review needed: List with reasoning +4. **Quality Metrics**: + - SonarQube gate: Pass/Fail + - CodeQL alerts: Count by severity + - Code coverage: % + - Build/test status: Pass/Fail + - **Cypress E2E tests**: Pass/Fail (X passed, Y failed) + - **Cypress artifacts**: Screenshots/videos if failures + +## Requirements + +- ✅ All SonarQube critical/blocker issues auto-fixed +- ✅ Security rating A/B (after fixes) +- ✅ No CodeQL critical/high vulnerabilities +- ✅ Code coverage ≥80% +- ✅ Build/tests pass (backend unit tests) +- ✅ **Cypress E2E tests pass** (frontend integration tests) +- ✅ ABP conventions followed +- ✅ AutoMapper/localization/permissions configured + +## Tool Usage + +### Analyzing Files +``` +Use sonarqube_analyze_file for each modified C# file to detect: +- Code quality issues +- Security vulnerabilities +- Maintainability problems +- Bug risks +``` + +### Listing Security Issues +``` +Use sonarqube_list_potential_security_issues to get: +- All security hotspots +- Vulnerabilities by severity +- Recommended fixes +``` + +### After Fixes +Re-run `sonarqube_analyze_file` on modified files to verify issues resolved. + +## Cypress E2E Testing + +### Location +- **Project**: `applications/Unity.AutoUI` +- **Config**: `cypress.config.ts` +- **Tests**: `cypress/` folder +- **Launcher**: `CypressTestLauncher.bat` (Windows) + +### Running Tests + +**Headless (CI/CD)**: +```bash +cd applications/Unity.AutoUI +npx cypress run +``` + +**Interactive Mode**: +```bash +cd applications/Unity.AutoUI +npx cypress open +``` + +**Using Launcher** (Windows): +```bash +cd applications/Unity.AutoUI +./CypressTestLauncher.bat +``` + +### What to Check +- ✅ All test specs pass +- ✅ No failed assertions +- ✅ Screenshots captured for failures (in `cypress/screenshots/`) +- ✅ Videos recorded (in `cypress/videos/`) +- ✅ No console errors or warnings +- ✅ UI rendering correctly + +### Failure Handling +If Cypress tests fail: +1. Review failure screenshots/videos +2. Check if UI changes broke existing tests +3. Update test selectors if component structure changed +4. Fix actual bugs if tests caught regressions +5. Re-run tests to verify fixes + +### Test Coverage Areas +Based on Unity.AutoUI project, tests likely cover: +- Grant application submission workflows +- Form validation +- User authentication/authorization +- Data table interactions +- File upload/download +- Multi-step wizards + +## Common Fixes + +```csharp +// ❌ SQL Injection +var sql = $"SELECT * FROM Users WHERE Email = '{email}'"; + +// ✅ Use EF LINQ +var users = await _dbContext.Users.Where(u => u.Email == email).ToListAsync(); + +// ❌ Missing authorization +public async Task DeleteAsync(Guid id) + +// ✅ Add attribute +[Authorize(GrantManagerPermissions.Applications.Delete)] +public async Task DeleteAsync(Guid id) + +// ❌ Return entity +public async Task GetAsync(Guid id) + +// ✅ Return DTO +public async Task GetAsync(Guid id) +{ + var entity = await _repository.GetAsync(id); + return ObjectMapper.Map(entity); +} + +// ❌ Path traversal +public async Task GetDocumentAsync(string fileName) +{ + var path = Path.Combine(root, "Documents", fileName); + return await File.ReadAllBytesAsync(path); +} + +// ✅ Validate path +public async Task GetDocumentAsync(Guid documentId) +{ + var doc = await _repository.GetAsync(documentId); + var safeFileName = Path.GetFileName(doc.FileName); + var fullPath = Path.GetFullPath(Path.Combine(root, "Documents", safeFileName)); + var allowedPath = Path.GetFullPath(Path.Combine(root, "Documents")); + + if (!fullPath.StartsWith(allowedPath)) + throw new BusinessException("Invalid path"); + + return await File.ReadAllBytesAsync(fullPath); +} +``` + +## References +- `.github/copilot-instructions.md` +- `.github/skills/unity-module-structure/SKILL.md` +- `.github/agents/unity-abp-instructions.md` diff --git a/applications/Unity.GrantManager/.github/agents/test-strategy.agent.md b/applications/Unity.GrantManager/.github/agents/test-strategy.agent.md new file mode 100644 index 0000000000..50c706d34f --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/test-strategy.agent.md @@ -0,0 +1,43 @@ +--- +name: test-strategy +description: Builds test strategy with xUnit, Shouldly, NSubstitute, and layered coverage. +tools: ['codebase', 'problems', 'usages', 'runTests', 'githubRepo'] +--- + +# ABP Test Strategy Agent + +You are the testing strategy specialist for Unity Grant Manager. + +## Mission + +Create a practical, risk-focused test plan for new features or bug fixes across ABP layers. + +## Inputs + +- Feature scope or code diff. +- Changed modules and layers. +- Known edge cases. + +## Process + +1. Identify impacted behavior per layer. +2. Split test coverage into unit, integration, and optional web tests. +3. Propose fixtures and test data setup. +4. Map scenarios to concrete test cases. +5. Prioritize tests for fastest feedback. + +## Output Format + +1. Coverage scope summary. +2. Unit test cases. +3. Integration test cases. +4. Test data and fixture requirements. +5. Execution order and commands. + +## Guardrails + +- Apply `.github/skills/unity-testing/SKILL.md`. +- Follow `.github/instructions/testing.instructions.md`. +- Use xUnit with Shouldly and NSubstitute. +- Avoid `Assert.*` and Moq patterns. +- Keep tests deterministic and isolated. diff --git a/applications/Unity.GrantManager/.github/agents/test-triage.agent.md b/applications/Unity.GrantManager/.github/agents/test-triage.agent.md new file mode 100644 index 0000000000..a0903abe7a --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/test-triage.agent.md @@ -0,0 +1,42 @@ +--- +name: test-triage +description: Diagnoses failing tests, isolates root cause, and proposes minimal-risk fixes. +tools: ['codebase', 'problems', 'usages', 'runTests', 'githubRepo'] +--- + +# ABP Test Triage Agent + +You are the failure triage specialist for Unity Grant Manager tests. + +## Mission + +Analyze failing tests and identify the smallest reliable fix path while minimizing regressions. + +## Inputs + +- Test output logs. +- Recent code diff. +- Affected project/module. + +## Process + +1. Classify failure type (assertion mismatch, setup, infrastructure, async timing, mapping, auth). +2. Correlate failing tests with changed code paths. +3. Identify probable root cause and confidence level. +4. Propose minimum fix sequence with verification steps. +5. Identify regression tests that must be added or updated. + +## Output Format + +1. Failure summary. +2. Root-cause hypotheses ranked by probability. +3. Recommended fix path. +4. Verification command checklist. +5. Regression prevention tests. + +## Guardrails + +- Use module/layer rules from `.github/skills/unity-module-structure/SKILL.md`. +- Use testing conventions from `.github/skills/unity-testing/SKILL.md`. +- Prefer minimal changes over broad refactors during triage. +- Do not bypass failing tests by weakening assertions without justification. diff --git a/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md b/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md new file mode 100644 index 0000000000..573577e9a5 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md @@ -0,0 +1,662 @@ +# ABP Framework Instructions for Unity Grant Manager + +## Project Overview +Unity Grant Manager is an ASP.NET Core MVC application built using ABP Framework 9.1.3, following Domain-Driven Design (DDD) principles. + +## Architecture & Technology Stack + +### Backend +- **Framework**: ABP Framework 9.1.3 (ASP.NET Core MVC) +- **Architecture**: Domain-Driven Design (DDD) +- **Pattern**: Multi-layered application (Domain, Application, Web) +- **UI Framework**: ABP MVC UI with Bootstrap 4 +- **ORM**: Entity Framework Core (inferred from ABP standard) + +### Frontend +- **UI Theme**: ABP Basic Theme (`@abp/aspnetcore.mvc.ui.theme.basic`) +- **JavaScript**: jQuery, DataTables.net +- **Form Builder**: FormIO (formiojs 4.17.4) +- **Charts**: ECharts 6.0 +- **Rich Text**: TinyMCE 8.3.2 +- **CSS**: Bootstrap 4.6.2 + +## Project Structure + +``` +Unity.GrantManager/ +├── src/ +│ ├── Unity.GrantManager.Domain/ # Domain layer (entities, aggregates, repositories) +│ ├── Unity.GrantManager.Domain.Shared/ # Shared domain concepts +│ ├── Unity.GrantManager.Application/ # Application services +│ ├── Unity.GrantManager.Application.Contracts/ # DTOs, interfaces +│ ├── Unity.GrantManager.Web/ # MVC UI layer +│ │ ├── Views/ # Razor views +│ │ │ └── Shared/Components/ # View components +│ │ ├── Controllers/ # MVC controllers +│ │ ├── wwwroot/ # Static files +│ │ └── Pages/ # Razor pages +│ └── Unity.GrantManager.HttpApi/ # Web API controllers +├── test/ # Test projects +└── modules/ # ABP modules +``` + +## ABP Framework Conventions + +### 1. Application Services +- Located in `*.Application` project +- Inherit from `ApplicationService` base class +- Use `AppService` suffix (e.g., `GrantApplicationAppService`) +- Return DTOs, not domain entities +- Handle authorization with `[Authorize]` attributes +- Use ABP's `IObjectMapper` for entity-to-DTO mapping + +```csharp +public class GrantApplicationAppService : ApplicationService, IGrantApplicationAppService +{ + private readonly IRepository _repository; + + public GrantApplicationAppService(IRepository repository) + { + _repository = repository; + } + + [Authorize(GrantManagerPermissions.GrantApplications.View)] + public async Task GetAsync(Guid id) + { + var entity = await _repository.GetAsync(id); + return ObjectMapper.Map(entity); + } +} +``` + +### 2. Domain Entities +- Located in `*.Domain` project +- Inherit from `Entity`, `AggregateRoot`, or `FullAuditedAggregateRoot` +- Use `FullAuditedAggregateRoot` for entities requiring audit trails +- Place business logic in entity methods, not in services +- Use domain events for cross-aggregate communication + +```csharp +public class GrantApplication : FullAuditedAggregateRoot +{ + public string ReferenceNo { get; private set; } + public decimal RequestedAmount { get; private set; } + public ApplicationStatus Status { get; private set; } + + public void Approve(decimal approvedAmount) + { + // Business logic here + Status = ApplicationStatus.Approved; + AddDistributedEvent(new ApplicationApprovedEvent(Id)); + } +} +``` + +### 3. Repositories +- Use `IRepository` for basic CRUD +- Create custom repositories in `*.Domain` for complex queries +- Repository interfaces in Domain, implementations in Infrastructure/EntityFrameworkCore + +### 4. DTOs (Data Transfer Objects) +- Located in `*.Application.Contracts` project +- Separate DTOs for create, update, and read operations +- Use `EntityDto` as base when including Id +- Example: `CreateGrantApplicationDto`, `UpdateGrantApplicationDto`, `GrantApplicationDto` + +### 5. MVC Controllers +- Located in `*.Web` project's `Controllers` folder +- Inherit from `AbpController` +- Use dependency injection for application services +- Return `IActionResult` or derived types +- Use ABP's localization: `L["KeyName"]` + +```csharp +public class GrantApplicationsController : AbpController +{ + private readonly IGrantApplicationAppService _applicationService; + + public GrantApplicationsController(IGrantApplicationAppService applicationService) + { + _applicationService = applicationService; + } + + public async Task Details(Guid applicationId) + { + var dto = await _applicationService.GetAsync(applicationId); + return View(dto); + } +} +``` + +### 6. View Components +- Located in `Views/Shared/Components/{ComponentName}/` +- Default view: `Default.cshtml` +- JavaScript file: `Default.js` (if needed) +- Invoke in views: `@await Component.InvokeAsync("ComponentName")` + +### 7. Localization +- Use `L` function in C#: `L["KeyName"]` +- Use `l` function in JavaScript: `l('KeyName')` +- Use `@L["KeyName"]` in Razor views +- Localization files in JSON format in `Localization` folder + +### 8. Permissions +- Define in `*Permissions.cs` files +- Use constants for permission names +- Check with `[Authorize(PermissionName)]` attribute or `await AuthorizationService.CheckAsync()` + +## Unity Grant Manager Specific Patterns + +### DataTables Integration +- Use `initializeDataTable()` helper function from `table-utils.js` +- Column definitions follow a consistent pattern with getter functions +- Enable server-side processing for large datasets +- Use `createNumberFormatter()` for currency formatting + +```javascript +const dataTable = initializeDataTable({ + dt: $('#TableId'), + defaultVisibleColumns: ['select', 'referenceNo', 'status'], + listColumns: getColumns(formatter, l), + maxRowsPerPage: 10, + defaultSortColumn: { name: 'submissionDate', dir: 'desc' }, + dataEndpoint: service.getList, + responseCallback: responseCallback, + actionButtons: actionButtons, + serverSideEnabled: true +}); +``` + +### Column Getter Pattern +- Create separate functions for each column definition +- Include `columnIndex` parameter for ordering +- Return object with: `title`, `data`, `name`, `className`, `render`, `index` + +```javascript +function getReferenceNoColumn(columnIndex) { + return { + title: 'Submission #', + data: 'referenceNo', + name: 'referenceNo', + className: 'data-table-header text-nowrap', + render: function (data, type, row) { + return `${data || ''}`; + }, + index: columnIndex + }; +} +``` + +### Form Handling +- Use ABP's form validation helpers +- Leverage FormIO for dynamic forms +- Handle form submission via AJAX with proper error handling + +### Date Handling +- Use `luxon` library for date manipulation +- Use ABP's `DateUtils.formatUtcDateToLocal()` helper +- Store dates in UTC, display in local timezone +- Format: `luxon.DateTime.fromISO(data).toUTC().toLocaleString()` + +### Currency Formatting +```javascript +const formatter = createNumberFormatter(); // From table-utils.js +formatter.format(amount); // Returns formatted currency string +``` + +## Best Practices + +### 1. Keep Business Logic in Domain +- Don't put business rules in controllers or views +- Use domain services for logic crossing multiple aggregates +- Application services orchestrate, domain entities execute + +### 2. Use ABP Conventions +- Follow ABP naming conventions (`AppService`, `Dto`, etc.) +- Use ABP's built-in features (authorization, localization, audit logging) +- Leverage ABP's dependency injection + +### 3. Maintain Layer Separation +- Domain layer has no dependencies on other layers +- Application layer depends only on Domain and Domain.Shared +- Web layer depends on Application.Contracts, not Domain directly + +### 4. Error Handling +- Use ABP's `UserFriendlyException` for user-facing errors +- Use ABP's `BusinessException` for business rule violations +- Let ABP's exception handling middleware manage responses + +### 5. JavaScript Organization +- Keep component-specific JS in component folders +- Extract reusable utilities to shared files (e.g., `table-utils.js`) +- Use function declarations for hoisted helpers +- Avoid duplicate function definitions + +### 6. Testing +- Write unit tests for domain logic +- Integration tests for application services +- Use ABP's test infrastructure + +## Common Operations + +### Adding a New Entity +1. Create entity in `*.Domain` project +2. Add to `DbContext` in `*.EntityFrameworkCore` +3. Create migration +4. Create DTOs in `*.Application.Contracts` +5. Create application service in `*.Application` +6. Add AutoMapper mappings +7. Define permissions +8. Create MVC controller and views + +### Database Migrations +```powershell +# From Unity.GrantManager.EntityFrameworkCore project directory +dotnet ef migrations add MigrationName +dotnet ef database update +``` + +### Adding Localization Keys +1. Add to `en.json` in `Localization/GrantManager` folder +2. Add translations for other supported languages +3. Use `L["KeyName"]` in code + +## Important Files & Utilities + +### JavaScript Utilities +- `table-utils.js`: DataTables initialization and helpers +- `DateUtils`: Date formatting utilities +- `createNumberFormatter()`: Currency formatting + +### Common JavaScript Patterns +```javascript +// Localization +const l = abp.localization.getResource('GrantManager'); + +// AJAX calls +abp.ajax({ + url: '/api/app/grant-application/...', + type: 'POST', + data: JSON.stringify(data) +}); + +// Notifications +abp.notify.success(l('SavedSuccessfully')); +abp.notify.error(l('ErrorOccurred')); +``` + +## ABP 9.1.3 Features for Unity Grant Manager + +### 1. Background Jobs for Long-Running Operations +**Use Cases**: Application processing, bulk operations, report generation, email notifications + +```csharp +// Define a background job +public class ProcessApplicationJob : AsyncBackgroundJob +{ + private readonly IGrantApplicationRepository _repository; + + public ProcessApplicationJob(IGrantApplicationRepository repository) + { + _repository = repository; + } + + public override async Task ExecuteAsync(ProcessApplicationArgs args) + { + var application = await _repository.GetAsync(args.ApplicationId); + // Process application logic + } +} + +// Enqueue a job +await _backgroundJobManager.EnqueueAsync(new ProcessApplicationArgs { ApplicationId = id }); +``` + +**Configuration** (in module class): +```csharp +Configure(options => +{ + options.IsJobExecutionEnabled = true; // Enable background job execution +}); +``` + +### 2. Blob Storage for Document Management +**Use Cases**: Storing application documents, attachments, generated reports + +```csharp +// Inject IBlobContainer +private readonly IBlobContainer _blobContainer; + +// Save a file +await _blobContainer.SaveAsync("document-name.pdf", stream, overrideExisting: true); + +// Retrieve a file +var stream = await _blobContainer.GetAsync("document-name.pdf"); + +// Delete a file +await _blobContainer.DeleteAsync("document-name.pdf"); +``` + +**Configuration** (module class): +```csharp +// Configure Blob Storage for different containers +Configure(options => +{ + options.Containers.Configure(container => + { + container.UseFileSystem(fileSystem => + { + fileSystem.BasePath = Path.Combine(hostingEnvironment.ContentRootPath, "Documents"); + }); + }); +}); +``` + +**Database Provider Alternative**: +```csharp +container.UseDatabase(); // Stores blobs in database +``` + +### 3. Global Feature System for Feature Toggles +**Use Cases**: Enable/disable features like assessment scoring, due diligence checks, payment processing + +```csharp +// Define features in Domain.Shared +public static class GrantManagerFeatures +{ + public const string AdvancedScoring = "GrantManager.AdvancedScoring"; + public const string AutomatedDueDiligence = "GrantManager.AutomatedDueDiligence"; + public const string PaymentIntegration = "GrantManager.PaymentIntegration"; +} + +// Configure in module +GlobalFeatureManager.Instance.Modules.GrantManager() + .EnableAll(); // Or .Enable(GrantManagerFeatures.AdvancedScoring) + +// Check feature in code +if (await FeatureChecker.IsEnabledAsync(GrantManagerFeatures.AdvancedScoring)) +{ + // Execute advanced scoring logic +} + +// In Razor views +@if (await FeatureChecker.IsEnabledAsync(GrantManagerFeatures.PaymentIntegration)) +{ + +} +``` + +### 4. Distributed Events for Workflow Management +**Use Cases**: Application state changes, notifications, audit trail, integration with external systems + +ABP 9.1.3 improves distributed event handling with better inbox/outbox pattern support. + +```csharp +// Define event (in Domain.Shared) +[Serializable] +public class ApplicationApprovedEto : EtoBase +{ + public Guid ApplicationId { get; set; } + public decimal ApprovedAmount { get; set; } +} + +// Publish event (in Application Service or Domain Entity) +await _distributedEventBus.PublishAsync(new ApplicationApprovedEto +{ + ApplicationId = id, + ApprovedAmount = amount +}); + +// Handle event (in Application layer) +public class ApplicationApprovedEventHandler : + IDistributedEventHandler, + ITransientDependency +{ + private readonly IEmailSender _emailSender; + + public ApplicationApprovedEventHandler(IEmailSender emailSender) + { + _emailSender = emailSender; + } + + public async Task HandleEventAsync(ApplicationApprovedEto eventData) + { + // Send approval email + // Create payment record + // Update external systems + } +} +``` + +**Configure Outbox for Reliability**: +```csharp +Configure(options => +{ + options.Outboxes.Configure(config => + { + config.UseDbContext(); + }); +}); +``` + +### 5. Enhanced Audit Logging +**Use Cases**: Track all changes to grant applications, compliance reporting, user activity monitoring + +ABP 9.1.3 provides better audit log filtering and querying. + +```csharp +// Disable auditing for specific method +[DisableAuditing] +public async Task GetLargeReportAsync() +{ + // Method not audited +} + +// Custom audit log properties +public class GrantApplicationAppService : ApplicationService +{ + public async Task ApproveAsync(Guid id, decimal amount) + { + // Add custom audit data + AuditingManager.Current.Log.EntityChanges.Add(new EntityChangeInfo + { + ChangeType = EntityChangeType.Updated, + EntityId = id.ToString(), + PropertyChanges = new List + { + new EntityPropertyChangeInfo + { + PropertyName = "ApprovalAmount", + NewValue = amount.ToString(), + OriginalValue = "0" + } + } + }); + } +} + +// Query audit logs (in a service) +var auditLogs = await _auditLogRepository.GetListAsync( + includeDetails: true, + httpMethod: "POST", + url: "/api/app/grant-application", + userName: "admin", + startTime: DateTime.UtcNow.AddDays(-7), + endTime: DateTime.UtcNow +); +``` + +### 6. Setting Management for Configurable Parameters +**Use Cases**: Approval thresholds, deadline configurations, scoring weights, notification preferences + +```csharp +// Define settings (in Domain.Shared) +public static class GrantManagerSettings +{ + public const string ApprovalThreshold = "GrantManager.ApprovalThreshold"; + public const string MaxApplicationsPerUser = "GrantManager.MaxApplicationsPerUser"; + public const string AutoCloseDeadlineDays = "GrantManager.AutoCloseDeadlineDays"; +} + +// Define setting definition provider +public class GrantManagerSettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + GrantManagerSettings.ApprovalThreshold, + "100000", + isVisibleToClients: true, + isEncrypted: false + ), + new SettingDefinition( + GrantManagerSettings.MaxApplicationsPerUser, + "5", + isVisibleToClients: true + ) + ); + } +} + +// Use settings in code +var threshold = await SettingProvider.GetAsync(GrantManagerSettings.ApprovalThreshold); + +if (amount > threshold) +{ + // Require additional approval +} + +// Get setting in JavaScript +var maxApps = await abp.setting.get('GrantManager.MaxApplicationsPerUser'); +``` + +### 7. Dynamic Claims for Custom Authorization +**Use Cases**: Department-based access, region-based filtering, role-based data visibility + +```csharp +// Define custom claim type +public static class GrantManagerClaims +{ + public const string Department = "GrantManager_Department"; + public const string Region = "GrantManager_Region"; + public const string MaxApprovalAmount = "GrantManager_MaxApprovalAmount"; +} + +// Add dynamic claims (in Identity module) +public class GrantManagerClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency +{ + public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context) + { + var identity = context.ClaimsPrincipal.Identities.FirstOrDefault(); + var userId = identity?.FindUserId(); + + if (userId.HasValue) + { + // Add custom claims from user profile or database + var userDepartment = await GetUserDepartmentAsync(userId.Value); + identity?.AddClaim(new Claim(GrantManagerClaims.Department, userDepartment)); + } + } +} + +// Use in authorization +[Authorize] +public async Task> GetMyDepartmentApplicationsAsync() +{ + var department = CurrentUser.FindClaimValue(GrantManagerClaims.Department); + return await _repository.GetListAsync(x => x.Department == department); +} +``` + +### 8. EF Core 8 Features (if using .NET 8+) +**New Capabilities**: JSON columns, raw SQL queries, complex type mapping + +```csharp +// JSON column mapping (for flexible metadata) +public class GrantApplication : FullAuditedAggregateRoot +{ + public string ReferenceNo { get; set; } + public ApplicationMetadata Metadata { get; set; } // Stored as JSON +} + +// In DbContext configuration +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity(b => + { + b.OwnsOne(e => e.Metadata, b => b.ToJson()); + }); +} + +// Raw SQL queries with better performance +var applications = await _dbContext.Database + .SqlQuery($"EXEC GetTopApplications @Year = {year}") + .ToListAsync(); +``` + +### 9. Object Extension System for Extensibility +**Use Cases**: Add custom fields without modifying core entities + +```csharp +// Configure in EntityFrameworkCore module +ObjectExtensionManager.Instance + .AddOrUpdateProperty( + "CustomField1", + options => { options.MapEfCore(b => b.HasMaxLength(128)); } + ); + +// Use in application service +application.SetProperty("CustomField1", "CustomValue"); +var value = application.GetProperty("CustomField1"); +``` + +### 10. Text Template Management +**Use Cases**: Email templates, document generation, notification templates + +```csharp +// Define template +public class ApprovalEmailTemplate : TemplateDefinitionProvider +{ + public override void Define(ITemplateDefinitionContext context) + { + context.Add( + new TemplateDefinition("ApprovalEmail") + .WithVirtualFilePath("/Templates/ApprovalEmail.tpl", isInlineLocalized: true) + ); + } +} + +// Use template +var emailBody = await _templateRenderer.RenderAsync( + "ApprovalEmail", + new { ApplicantName = "John Doe", Amount = 50000 } +); +``` + +## Module Structure +Unity Grant Manager includes: +- **Unity.Shared**: Shared components across Unity applications +- **MessageBrokers**: RabbitMQ integration (consider using ABP distributed events) +- **modules/**: Various ABP modules + +## Additional Resources +- ABP Framework Documentation: https://docs.abp.io +- ABP 9.1 Release Notes: https://docs.abp.io/en/abp/9.1/Release-Info +- Project README: `/Unity/applications/Unity.GrantManager/README.md` +- Architecture documentation: `/Unity/documentation/` + +## Recommended Next Steps for ABP 9.1.3 Integration + +1. **Implement Blob Storage** for document management (replace file system storage) +2. **Add Distributed Events** for application workflow state changes +3. **Configure Background Jobs** for report generation and notifications +4. **Use Setting Management** for configurable business rules (thresholds, deadlines) +5. **Leverage Global Features** for feature flags in production +6. **Enhance Audit Logging** for compliance requirements +7. **Implement Dynamic Claims** for department/region-based access control +8. **Use Text Templates** for standardized email and document generation + +--- + +**Remember**: This is an ABP Framework MVC application, NOT Angular. Use Razor views, jQuery, and traditional server-side rendering patterns. diff --git a/applications/Unity.GrantManager/.github/commit-message-instructions.md b/applications/Unity.GrantManager/.github/commit-message-instructions.md new file mode 100644 index 0000000000..f716456f33 --- /dev/null +++ b/applications/Unity.GrantManager/.github/commit-message-instructions.md @@ -0,0 +1 @@ +Format commit message starting with [AB#] where is extracted from the branch name (e.g., from 'feature/AB#32037-...' extract '32037' as ID), followed by a short description. Aim for 50 characters after the prefix when possible but prioritize clarity. \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/copilot-instructions.md b/applications/Unity.GrantManager/.github/copilot-instructions.md new file mode 100644 index 0000000000..b54ee4fc9b --- /dev/null +++ b/applications/Unity.GrantManager/.github/copilot-instructions.md @@ -0,0 +1,204 @@ +# Unity Grant Manager – Copilot Instructions + +> **Trust these instructions first.** Only search the codebase when information here is incomplete or incorrect. +> This is NOT the Unity game engine. Do not suggest UnityEngine APIs. + +## Project Overview + +Unity Grant Manager is a **grant management portal** for the Province of British Columbia, built on **ABP Framework 9.1.3** with **.NET 9.0**, targeting **PostgreSQL 17**. The UI uses **Razor Pages** with a custom ABP theme (Unity.Theme.UX2). The architecture follows ABP's **Domain-Driven Design (DDD)** layered module pattern. + +**Key stack:** .NET 9 · ABP 9.1.3 · EF Core 9.0 · PostgreSQL 17 · Redis · RabbitMQ · xUnit · Shouldly · NSubstitute · AutoMapper · Cypress (E2E) + +## Repository Layout + +``` +Unity.GrantManager.sln ← Solution file (63 projects) +common.props ← Shared MSBuild properties +Directory.Build.props ← Global build props (suppresses NU1701, MSB3277) +NuGet.Config ← NuGet package sources +.env.example ← Environment variable template +docker-compose.yml ← Docker dev environment +src/ + Unity.GrantManager.Web/ ← Razor Pages web app (entry point) + Unity.GrantManager.Application/ ← App services, AutoMapper profiles + Unity.GrantManager.Application.Contracts/ ← DTOs, interfaces + Unity.GrantManager.Domain/ ← Entities, repositories, domain services + Unity.GrantManager.Domain.Shared/ ← Enums, constants, localization (en.json) + Unity.GrantManager.EntityFrameworkCore/ ← DbContext, migrations, EF config + Unity.GrantManager.HttpApi/ ← REST controllers + Unity.GrantManager.HttpApi.Client/ ← Remote service client proxies + Unity.GrantManager.DbMigrator/ ← Database migration console app +test/ + Unity.GrantManager.TestBase/ ← Shared test fixtures + Unity.GrantManager.Application.Tests/ ← App service tests + Unity.GrantManager.Domain.Tests/ ← Domain logic tests + Unity.GrantManager.EntityFrameworkCore.Tests/ + Unity.GrantManager.Web.Tests/ +modules/ ← ABP modules (each with src/ and test/) + Unity.Flex/ ← Dynamic forms/worksheets + Unity.Notifications/ ← Email/messaging (full-stack module) + Unity.Payments/ ← Financial transactions + Unity.Reporting/ ← Analytics & reports + Unity.AI/ ← AI-powered analysis (OpenAI) + Unity.TenantManagement/ ← Multi-tenant admin + Unity.Identity.Web/ ← OIDC authentication + Unity.Theme.UX2/ ← Custom Razor Pages theme + Unity.SharedKernel/ ← Cross-cutting utilities +``` + +## Build & Test Commands + +All commands run from this directory (`applications/Unity.GrantManager/`). + +### Restore & Build + +```bash +dotnet restore Unity.GrantManager.sln +dotnet build Unity.GrantManager.sln --no-restore +``` + +- Build takes ~3 minutes. The solution has 63 projects. +- There is **1 expected warning** in `Unity.GrantManager.Web/Pages/Dashboard/Index.cshtml.cs` (CS8604 null reference). Do not try to fix it unless explicitly asked. +- `Directory.Build.props` suppresses NU1701 and MSB3277 warnings globally; do not re-add these suppressions in individual projects. +- `common.props` sets `latest` and suppresses CS1591. + +### Run All Tests + +```bash +dotnet test Unity.GrantManager.sln --no-build +``` + +- ~470 tests across 15 test projects. All use **xUnit** with **Shouldly** assertions and **NSubstitute** mocks. +- Tests use in-memory database providers (**SQLite** for most projects; **EFCore.InMemory** for `Unity.GrantManager.Web.Tests`) instead of PostgreSQL. No database setup required. +- Test run takes ~1–2 minutes after build. +- To run a single test project: `dotnet test test/Unity.GrantManager.Application.Tests/ --no-build` + +### EF Core Migrations + +The solution has **two database contexts** — always specify which context: + +```bash +cd src/Unity.GrantManager.EntityFrameworkCore + +# Host migrations (shared/system tables) +dotnet ef migrations add --context GrantManagerDbContext --output-dir Migrations/HostMigrations + +# Tenant migrations (per-tenant isolated data) +dotnet ef migrations add --context GrantTenantDbContext --output-dir Migrations/TenantMigrations +``` + +## CI Pipeline (PR Checks) + +Every PR triggers branch-specific GitHub Actions workflows (in the repo root `.github/workflows/`) that: + +1. **Validate source branch** — PRs to `dev` must come from `feature/*`, `hotfix/*`, `bugfix/*`, `test`, or `main`. PRs to `main` must come from `test` or `hotfix/*` only. +2. **Discover test projects** — Finds all `*Tests.csproj` files automatically. +3. **Run tests in parallel matrix** — Each test project runs independently with `dotnet test` using .NET 9.0.x. +4. **Aggregate results** — Posts pass/fail badge as PR comment. + +Always ensure `dotnet build` and `dotnet test` pass before submitting changes. + +## Architecture Rules + +### ABP Layering (Current Wiring + Preferred Usage) + +Current solution references are broader than a strict textbook layer graph: + +``` +Web → Application + HttpApi + HttpApi.Client + EntityFrameworkCore +HttpApi → Application.Contracts + Domain +Application → Domain + Application.Contracts +Domain → Domain.Shared +EntityFrameworkCore → Domain +``` + +Preferred code-usage boundaries (guidance for new code): + +- Prefer keeping web/UI concerns out of Application and Domain. +- Prefer keeping business rules in Domain entities/managers, not in controllers or pages. +- Prefer controllers/endpoints to use Application.Contracts surfaces for use-case orchestration. +- Avoid introducing new cross-layer dependencies unless there is a clear ABP/module-composition reason. +- Do not call other application services within the same module; move shared logic to Domain services/helpers. + +### Entity & Domain Conventions + +- Entities use **rich domain model**: private/protected setters, behavior via methods. +- Include `protected` parameterless constructor for EF Core. +- Do **not** generate `Guid` keys inside constructors; accept `id` from `IGuidGenerator`. +- Reference other aggregate roots **by Id only**, not navigation properties. +- Domain services use `*Manager` suffix. +- Throw `BusinessException` with namespaced error codes for rule violations. + +### Application Layer + +- Interface naming: `I*AppService` inheriting `IApplicationService`. +- All methods `async`, end with `Async`. +- Accept/return **DTOs only**, never entities. Define DTOs in `*.Application.Contracts`. +- Make all public methods `virtual` for extensibility. +- This project uses **AutoMapper** (not Mapperly). Mapping profiles are `*AutoMapperProfile.cs` inheriting `Profile`. +- `ObjectMapper.Map<>()` is used for DTO mapping, not Mapperly partials. + +### EF Core + +- Entity configuration uses extension methods (`ConfigureMyProject()` on `ModelBuilder`), not inline in `OnModelCreating`. +- Always call `b.ConfigureByConvention()` for every entity mapping. +- Use `options.AddDefaultRepositories()` without `includeAllEntities: true`. +- Repository implementations inherit `EfCoreRepository`. + +### Testing Conventions + +- Test class naming: `*Tests.cs` +- Base class hierarchy: `AbpIntegratedTest` → `GrantManagerTestBase` → domain-specific bases. +- Use `[Fact]` for single tests, `[Theory]` with `[InlineData]` for parameterized. +- Assertions: `Shouldly` (`result.ShouldBe(expected)`, `result.ShouldNotBeNull()`). +- Mocking: `NSubstitute` (`Substitute.For()`). +- JSON test fixtures loaded from `AppDomain.CurrentDomain.BaseDirectory`. + +### Code Style + +- **Prettier** for JS/CSS: single quotes, 4-space tabs, no tabs. +- **C#**: Latest language version, nullable enabled in most projects. +- Localization: English strings in `*/Localization/*/en.json`. All user-facing text must use localization; no hardcoded English in code. +- Permissions defined in `*PermissionDefinitionProvider` in Application.Contracts. + +### Branching + +- `dev` → `test` → `main` promotion flow. +- Feature work: `feature/*`, bug fixes: `bugfix/*`, urgent fixes: `hotfix/*`. + +## Skills + +For detailed ABP patterns (DDD, application services, EF Core, testing, module architecture), refer to `.github/skills/` for domain-specific guidance. + +## Agents + +Use the ABP workflow agents in `.github/agents/` to accelerate planning, development, and testing. + +### When to Use Each Agent + +- `feature-planner`: Convert a feature request or bug into a layered ABP implementation plan. +- `ddd-modeler`: Design or review aggregates, invariants, repositories, and domain managers. +- `application-service-designer`: Define app service contracts, DTOs, authorization, and AutoMapper changes. +- `efcore-migration-planner`: Plan host vs tenant EF Core model updates and migration steps safely. +- `permissions-localization-auditor`: Audit diffs for permission coverage, localization keys, and hardcoded text. +- `test-strategy`: Build risk-based unit/integration test plans for ABP features. +- `test-triage`: Diagnose failing tests and propose minimal-risk fixes with verification steps. +- `pr-readiness`: Run ABP-focused pre-PR quality checks (layering, mapping, tests, migration correctness). + +### Usage Guidance + +Provide these inputs when invoking an agent: + +1. Feature or bug context. +2. Target module(s) and affected layers. +3. Tenant scope (host, tenant, or both). +4. Constraints (security, timeline, performance, backward compatibility). + +Use multiple agents in sequence for larger work: + +1. `feature-planner` +2. `ddd-modeler` and/or `abp-application-service-designer` +3. `efcore-migration-planner` (if schema changes exist) +4. `test-strategy` or `abp-test-triage` +5. `permissions-localization-auditor` +6. `pr-readiness` diff --git a/applications/Unity.GrantManager/.github/instructions/csharp.instructions.md b/applications/Unity.GrantManager/.github/instructions/csharp.instructions.md new file mode 100644 index 0000000000..aa9978fb26 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/csharp.instructions.md @@ -0,0 +1,120 @@ +--- +applyTo: "**/*.cs" +description: "C# and .NET 9 development standards for ABP Framework 9.1.3" +--- + +# C# Conventions for Unity Grant Manager + +- Target framework: .NET 9.0 with `latest`. +- Nullable reference types are enabled in most projects. +- This is an ABP Framework project. Use ABP base classes, not raw ASP.NET Core. +- This is NOT the Unity game engine. Do not suggest UnityEngine APIs. + +## ABP Base Classes + +- Application Services: Inherit `ApplicationService`, implement interface from Application.Contracts +- Domain Services: Inherit `DomainService`, use `Manager` suffix +- Entities: Inherit `FullAuditedAggregateRoot` or `AuditedAggregateRoot` +- API Controllers: Inherit `AbpController` +- Repositories: Use `IRepository` by default; custom only when needed + +### Injected Properties Available in Base Classes + +These properties are pre-injected in `ApplicationService`, `DomainService`, and `AbpController`: + +| Property | Purpose | +|---|---| +| `GuidGenerator` | Create new entity IDs — never use `Guid.NewGuid()` | +| `Clock` | Use `Clock.Now` — never use `DateTime.Now` or `DateTime.UtcNow` | +| `CurrentUser` | Access authenticated user (Id, Name, Email, Roles) | +| `CurrentTenant` | Access current tenant context (Id, Name) | +| `L` / `L["Key"]` | Localization shortcut | +| `ObjectMapper` | AutoMapper-based mapping | +| `Logger` | Structured logging via `ILogger` | +| `AuthorizationService` | Programmatic authorization checks | +| `UnitOfWorkManager` | Manual unit-of-work control | + +## Dependency Injection + +- ABP auto-registers services using marker interfaces — do NOT manually call `services.AddScoped<>()` +- `ITransientDependency` — new instance per injection +- `ISingletonDependency` — single shared instance +- `IScopedDependency` — one per request +- Application services, domain services, and repositories are auto-registered by ABP + +## Entities & Domain + +- Entities use rich domain model: private/protected setters, behaviour via methods. +- Include `protected` parameterless constructor for EF Core deserialization. +- Do not generate `Guid` keys inside constructors; accept `id` from `IGuidGenerator`. +- Reference other aggregate roots by Id only, not navigation properties. +- Domain services use `*Manager` suffix. +- Throw `BusinessException` with namespaced error codes for rule violations. + +## Application Services + +- Interface naming: `I*AppService` inheriting `IApplicationService`. +- All methods `async`, name ends with `Async`. +- Accept/return DTOs only, never entities. Define DTOs in `*.Application.Contracts`. +- Make all public methods `virtual`. +- Use **AutoMapper** (`ObjectMapper.Map<>()`) for DTO mapping. Do NOT use Mapperly. +- Mapping profiles: `*AutoMapperProfile.cs` inheriting `Profile`. + +## Code Style + +- 4 spaces indentation, no tabs +- No emojis in comments +- Always use braces, even for single-line statements +- Use `nameof` instead of string literals when referring to member names +- Prefer pattern matching and switch expressions where appropriate +- All user-facing text must be localized via `L["Key"]`. No hardcoded English strings. +- Permissions defined in `*PermissionDefinitionProvider` in Application.Contracts. +- Do not call other application services within the same module; push shared logic to domain services. + +## Naming Conventions + +- Follow PascalCase for public members, types, and methods +- Use camelCase for private fields and local variables +- Prefix interface names with `I` +- Domain Services: `*Manager` suffix (e.g., `AssessmentManager`) +- Application Services: `*AppService` suffix (e.g., `ApplicationAppService`) +- DTOs: Descriptive suffixes (`CreateApplicationDto`, `UpdateApplicationDto`, `ApplicationDto`) +- Event Transfer Objects: `*Eto` suffix for distributed events + + +## DTOs vs Entities + +- Application services MUST accept and return DTOs only, never entities +- Use `ObjectMapper` (AutoMapper) to map between entities and DTOs +- Define mapping profiles in `*AutoMapperProfile` class in Application project + +## Authorization + +- Apply `[Authorize(PermissionName)]` attributes on application service methods +- Define permissions in `*Permissions` static class in Domain.Shared project + +## Multi-Tenancy + +- Tenant entities MUST implement `IMultiTenant` interface +- NEVER manually filter by `TenantId` — ABP handles this automatically +- Use `GrantTenantDbContext` for tenant data, `GrantManagerDbContext` for host data + +## Error Handling + +- Use `BusinessException` for domain-level errors with namespaced error codes (e.g., `"GrantManager:ApplicationNotFound"`) +- Map error codes to localization keys for user-friendly messages +- Use `.WithData("key", value)` for localized message interpolation +- Catch specific exception types, not generic `Exception` + +## Common Mistakes to Avoid + +- Don't expose entities from application services — always return DTOs +- Don't put business logic in application services — use domain services +- Don't create custom repositories unnecessarily — use generic `IRepository` first +- Don't mix host and tenant data in same DbContext +- Don't ignore nullable warnings — fix them properly +- Don't use `DateTime.Now` — use `Clock.Now` or inject `IClock` +- Don't use `Guid.NewGuid()` — use `GuidGenerator.Create()` +- Don't use `services.AddScoped<>()` for ABP services — use marker interfaces +- Don't call application services from within the same module — extract shared logic to a domain service +- Don't embed entity name in app service methods — use `GetAsync`, not `GetApplicationAsync` diff --git a/applications/Unity.GrantManager/.github/instructions/efcore.instructions.md b/applications/Unity.GrantManager/.github/instructions/efcore.instructions.md new file mode 100644 index 0000000000..d308722720 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/efcore.instructions.md @@ -0,0 +1,25 @@ +--- +applyTo: "**/EntityFrameworkCore/**/*.cs" +--- + +# EF Core Conventions for Unity Grant Manager + +- Provider: **Npgsql** (PostgreSQL 17). +- Two database contexts: `GrantManagerDbContext` (host) and `GrantTenantDbContext` (tenant). +- Entity configuration is done inline in `OnModelCreating` of `GrantManagerDbContext` and `GrantTenantDbContext`. +- When configuring entities, follow ABP conventions (e.g., table naming, key configuration) consistently. +- Use `options.AddDefaultRepositories(includeAllEntities: true)` in `GrantManagerEntityFrameworkCoreModule`. +- Prefer ABP's generated default repositories; add custom repositories only when additional behavior is required. +- Tests use **SQLite in-memory** databases, not PostgreSQL. + +## Migrations + +Always specify the context when adding migrations: + +```bash +# Host migrations +dotnet ef migrations add --context GrantManagerDbContext --output-dir Migrations/HostMigrations + +# Tenant migrations +dotnet ef migrations add --context GrantTenantDbContext --output-dir Migrations/TenantMigrations +``` diff --git a/applications/Unity.GrantManager/.github/instructions/javascript.instructions.md b/applications/Unity.GrantManager/.github/instructions/javascript.instructions.md new file mode 100644 index 0000000000..075bffde01 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/javascript.instructions.md @@ -0,0 +1,54 @@ +--- +applyTo: "**/*.js" +description: "JavaScript development standards for ABP Framework frontend patterns" +--- + +# JavaScript Development Standards + +- Variables should be declared with "let" or "const" instead of "var" + +## General Patterns + +- Wrap all page scripts in IIFE: `(function ($) { ... })(jQuery);` +- Never create global JavaScript variables +- Use `var l = abp.localization.getResource('GrantManager');` for all user-facing text +- Use ABP's dynamic JavaScript API client proxies instead of manual AJAX + +## ABP JavaScript Utilities + +- Notifications: `abp.notify.success()`, `.error()`, `.warn()`, `.info()` +- Confirmation: `abp.message.confirm()` for destructive actions +- Authorization: `abp.auth.isGranted()` for permission checks +- Busy indicators: `abp.ui.setBusy()` / `abp.ui.clearBusy()` +- Localization: `l('LocalizationKey')` — never hardcode user-facing strings + +## DataTables Integration + +- Use DataTables.net 2.x with Bootstrap 5 integration (`datatables.net-bs5`) +- Always wrap configuration with `abp.libs.datatables.normalizeConfiguration()` +- Use `abp.libs.datatables.createAjax()` for server-side pagination +- Use `rowAction` for action buttons with `abp.auth.isGranted()` visibility checks +- Use `dataFormat` property for automatic date/boolean formatting +- Always call `dataTable.ajax.reload()` after CRUD operations + +## Modal Manager + +- Use `abp.ModalManager` for all modal dialogs +- Configure with `viewUrl`, `scriptUrl`, and `modalClass` +- Implement `onResult()` callback to reload DataTable after save +- Modal script classes: register in `abp.modals.*` namespace +- Return `NoContent()` from Razor Page handler to close modal + +## DOM Auto-Initialization + +- ABP auto-initializes: tooltips, popovers, datepickers, AJAX forms, autocomplete selects +- Use `data-bs-toggle="tooltip"` for tooltips +- Use `class="auto-complete-select"` with `data-autocomplete-*` attributes for lookups +- Use `data-ajaxForm="true"` for AJAX form submission + +## Client-Side Package Management + +- Add NPM packages to `package.json`, prefer `@abp/*` packages +- Configure `abp.resourcemapping.js` to map from `node_modules` to `wwwroot/libs` +- Run `abp install-libs` to copy resources +- Add to bundle contributor in `Unity.Theme.UX2` module \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/instructions/security.instructions.md b/applications/Unity.GrantManager/.github/instructions/security.instructions.md new file mode 100644 index 0000000000..bf95dea629 --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/security.instructions.md @@ -0,0 +1,48 @@ +--- +applyTo: "**/*.cs,**/*.cshtml,**/*.js" +description: "Security best practices for Unity Grant Manager" +--- + +# Security Standards + +## Authorization + +- Apply `[Authorize(PermissionName)]` attributes on all application service methods +- Define permissions in `*Permissions` static class in Domain.Shared project +- Use `abp.auth.isGranted()` in JavaScript for UI permission checks +- Never rely solely on UI-level permission hiding — always enforce server-side + +## Multi-Tenancy Security + +- Never manually filter by `TenantId` — ABP handles tenant isolation automatically +- Ensure tenant-scoped entities implement `IMultiTenant` +- Test cross-tenant data isolation explicitly +- Use `GrantTenantDbContext` for tenant data, `GrantManagerDbContext` for host data +- Be cautious with `[IgnoreMultiTenancy]` — understand the security implications + +## Input Validation + +- Validate all inputs at the application service boundary using data annotations or FluentValidation +- Use ABP's `Check.*` methods for domain-level validation (e.g., `Check.NotNullOrWhiteSpace`) +- Sanitize user inputs before storage — prevent XSS and injection attacks +- Use parameterized queries — never concatenate user input into SQL + +## Secrets Management + +- Never commit secrets, connection strings, or API keys to source code +- Use environment variables or secure configuration providers +- Reference `.env.example` for required environment variables +- Sensitive configuration is stored in OpenShift secrects and Hashicorp Vault when deployed + +## Authentication + +- Authentication is handled via Keycloak (OpenID Connect) +- Do not implement custom authentication — use ABP's identity infrastructure +- Ensure all API endpoints require authentication unless explicitly public + +## Data Protection + +- Use Redis-backed data protection for key storage in distributed deployments +- Encrypt sensitive data at rest when required by compliance +- Follow government security standards (BC Government policies) +- Audit logging is enabled via ABP — ensure sensitive operations are captured \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/instructions/testing.instructions.md b/applications/Unity.GrantManager/.github/instructions/testing.instructions.md new file mode 100644 index 0000000000..5117256fcd --- /dev/null +++ b/applications/Unity.GrantManager/.github/instructions/testing.instructions.md @@ -0,0 +1,30 @@ +--- +applyTo: "**/test/**/*.cs" +--- + +# Testing Conventions for Unity Grant Manager + +- Framework: **xUnit 2.9.3** with **Shouldly 4.3.0** assertions and **NSubstitute 5.3.0** mocks. +- Tests use in-memory database providers (SQLite in-memory for most test projects; `Unity.GrantManager.Web.Tests` uses `Microsoft.EntityFrameworkCore.InMemory`). No external PostgreSQL/database setup is required. +- Test class naming: `*Tests.cs`. +- Base class hierarchy: `AbpIntegratedTest` → `GrantManagerTestBase` → domain-specific bases. +- Use `[Fact]` for single tests, `[Theory]` with `[InlineData]` for parameterized. +- Assertions: Shouldly (`result.ShouldBe(expected)`, `result.ShouldNotBeNull()`). Do NOT use `Assert.*`. +- Mocking: NSubstitute (`Substitute.For()`). Do NOT use Moq. +- JSON test fixtures loaded from `AppDomain.CurrentDomain.BaseDirectory`. +- Run all tests: `dotnet test Unity.GrantManager.sln --no-build` +- Test method naming: `Should_[Expected]_[Scenario]` +- Follow Arrange-Act-Assert pattern consistently +- Do not emit "Arrange", "Act", or "Assert" comments in generated tests + +## Multi-Tenancy Testing + +- Test tenant data isolation using `CurrentTenant.Change(tenantId)` +- Verify that data created in one tenant is not visible in another +- Test both host-level and tenant-level operations + +## Test Data Management + +- Use helper methods for test data creation (e.g., `CreateTestApplicationAsync()`) +- Use static test data constants for well-known IDs +- Keep test data self-contained — each test should set up its own state diff --git a/applications/Unity.GrantManager/.github/skills/abp-cli/SKILL.md b/applications/Unity.GrantManager/.github/skills/abp-cli/SKILL.md new file mode 100644 index 0000000000..df1fd7b055 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/abp-cli/SKILL.md @@ -0,0 +1,78 @@ +--- +name: abp-cli +description: ABP CLI commands - generate-proxy, install-libs, add-package-ref, new-module, install-module, abp update, abp clean, abp suite generate. Use when the user asks how to run ABP CLI commands, generate proxies, install libraries, or use ABP Suite. +--- + +# ABP CLI Commands + +> **Full documentation**: https://abp.io/docs/latest/cli +> Use `abp help [command]` for detailed options. + +## Generate Client Proxies + +```bash +# URL flag: `-u` (short) or `--url` (long). Use whichever your team prefers, but keep it consistent. +# +# Angular (host must be running) +abp generate-proxy -t ng + +# C# client proxies +abp generate-proxy -t csharp -u https://localhost:44300 + +# Integration services only (microservices) +abp generate-proxy -t csharp -u https://localhost:44300 -st integration + +# JavaScript +abp generate-proxy -t js -u https://localhost:44300 +``` + +## Install Client-Side Libraries + +```bash +# Install NPM packages for MVC/Blazor Server +abp install-libs +``` + +## Add Package Reference + +```bash +# Add project reference with module dependency +abp add-package-ref Acme.BookStore.Domain +abp add-package-ref Acme.BookStore.Domain -t Acme.BookStore.Application +``` + +## Module Operations + +```bash +# Create new module in solution +abp new-module Acme.OrderManagement -t module:ddd + +# Install published module +abp install-module Volo.Blogging + +# Add ABP NuGet package +abp add-package Volo.Abp.Caching.StackExchangeRedis +``` + +## Update & Clean + +```bash +abp update # Update all ABP packages +abp update --version 8.0.0 # Specific version +abp clean # Delete bin/obj folders +``` + +## Quick Reference + +| Task | Command | +|------|---------| +| Angular proxies | `abp generate-proxy -t ng` | +| C# proxies | `abp generate-proxy -t csharp -u URL` | +| Install JS libs | `abp install-libs` | +| Add reference | `abp add-package-ref PackageName` | +| Create module | `abp new-module ModuleName` | +| Install module | `abp install-module ModuleName` | +| Update packages | `abp update` | +| Clean solution | `abp clean` | +| Suite CRUD | `abp suite generate -e entity.json -s solution.sln` | +| Get help | `abp help [command]` | \ No newline at end of file diff --git a/applications/Unity.GrantManager/.github/skills/unity-application-layer/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-application-layer/SKILL.md new file mode 100644 index 0000000000..f68910980b --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-application-layer/SKILL.md @@ -0,0 +1,91 @@ +--- +name: unity-application-layer +description: ABP Application Services, DTOs, AutoMapper profiles, validation, and error handling for Unity. Use when creating or modifying app services, DTOs, or mapping profiles in Application or Application.Contracts projects. +--- + +# Unity Application Layer Patterns + +## Application Service Contracts (Application.Contracts) + +- Interface naming: `I*AppService` inheriting `IApplicationService`. +- Define DTOs in `*.Application.Contracts` — never in Domain or Web. +- All methods async, end with `Async`. +- Do NOT repeat entity name in method names: use `GetAsync`, not `GetGrantAsync`. + +```csharp +public interface IGrantAppService : IApplicationService +{ + Task GetAsync(Guid id); + Task> GetListAsync(GetGrantListInput input); + Task CreateAsync(CreateGrantDto input); + Task UpdateAsync(Guid id, UpdateGrantDto input); // ID separate from DTO + Task DeleteAsync(Guid id); +} +``` + +## DTO Conventions + +| Purpose | Convention | Example | +|---------|------------|---------| +| Query input | `Get{Entity}Input` | `GetGrantInput` | +| List query | `Get{Entity}ListInput` | `GetGrantListInput` | +| Create input | `Create{Entity}Dto` | `CreateGrantDto` | +| Update input | `Update{Entity}Dto` | `UpdateGrantDto` | +| Output | `{Entity}Dto` | `GrantDto` | + +- Use data annotations for validation; reuse constants from Domain.Shared. +- Do NOT share input DTOs between methods. +- Do NOT put logic in DTOs (except `IValidatableObject` when necessary). + +## Implementation (Application) + +- Inherit from `ApplicationService`. +- Make all public methods `virtual`. +- Prefer `protected virtual` over `private` for helper methods. +- Use dedicated repositories, not inline LINQ in app services. +- Call `repository.UpdateAsync()` explicitly after mutations (don't assume change tracking). +- Do NOT use web types (`IFormFile`, `Stream`) — accept `byte[]` from controllers. +- Do NOT call other app services in the same module. Use domain services or repositories. + +## Object Mapping (AutoMapper) + +This project uses **AutoMapper** (not Mapperly). Mapping profiles are defined as: + +```csharp +public class GrantManagerApplicationAutoMapperProfile : Profile +{ + public GrantManagerApplicationAutoMapperProfile() + { + CreateMap(); + CreateMap(); + } +} +``` + +- Profile files follow `*AutoMapperProfile.cs` naming. +- Each Application and Web project has its own profile. +- Use `ObjectMapper.Map(source)` in app services. + +## Error Handling + +```csharp +// Business rule violation — use namespaced error code +throw new BusinessException("GrantManager:DuplicateName") + .WithData("Name", name); + +// Entity not found +throw new EntityNotFoundException(typeof(Grant), id); + +// User-facing message (use localized string) +throw new UserFriendlyException(L["GrantNotAvailable"]); +``` + +## Authorization + +- Use `[Authorize(PermissionName)]` on service methods. +- Permission names defined as constants in `*Permissions` classes in Application.Contracts. + +## Cross-Module Calls + +- You MAY call other modules' app services via their Application.Contracts interfaces. +- Do NOT call app services within the same module — use domain services. diff --git a/applications/Unity.GrantManager/.github/skills/unity-domain-driven-design/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-domain-driven-design/SKILL.md new file mode 100644 index 0000000000..0f309f4998 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-domain-driven-design/SKILL.md @@ -0,0 +1,105 @@ +--- +name: unity-domain-driven-design +description: DDD patterns for Unity - Entities, Aggregate Roots, Repositories, Domain Services, Domain Events. Use when creating or modifying entities, repositories, or domain services in Domain or Domain.Shared projects. +--- + +# Unity ABP DDD Patterns + +> Based on ABP Framework DDD conventions. This project uses ABP 9.1.3 with PostgreSQL 17 and EF Core 9.0. + +## Entities + +- Define entities in `*.Domain` projects. +- Use **rich domain model**: private/protected setters with methods that enforce invariants. +- Always provide a `protected` parameterless constructor for EF Core. +- Accept `Guid id` in the primary constructor; do NOT generate GUIDs inside constructors. Use `IGuidGenerator` from calling code. +- Make members `virtual` for ORM proxy compatibility. +- Initialize sub-collections in the primary constructor. + +```csharp +public class Grant : AuditedAggregateRoot +{ + public string Name { get; private set; } + public GrantStatus Status { get; private set; } + public ICollection Applications { get; private set; } + + protected Grant() { } // For EF Core + + public Grant(Guid id, string name) : base(id) + { + Name = Check.NotNullOrWhiteSpace(name, nameof(name)); + Status = GrantStatus.Draft; + Applications = new List(); + } + + public void SetName(string name) + { + Name = Check.NotNullOrWhiteSpace(name, nameof(name)); + } +} +``` + +## Aggregate Roots + +- Use a single `Id` property, prefer `Guid` keys. +- Inherit from `AggregateRoot` or audited base classes (`AuditedAggregateRoot`, `FullAuditedAggregateRoot`). +- Reference other aggregate roots **by Id only** — no cross-aggregate navigation properties. +- Keep aggregates small. + +## Repositories + +- Define repository interfaces in the Domain layer. +- One repository per aggregate root only. Never create repositories for child entities. +- Custom repository interface should inherit `IRepository`. +- All methods async with `CancellationToken cancellationToken = default`. +- Single-entity methods: `includeDetails = true` by default. +- List methods: `includeDetails = false` by default. + +```csharp +public interface IGrantRepository : IRepository +{ + Task FindByNameAsync(string name, bool includeDetails = true, CancellationToken cancellationToken = default); + Task> GetListByStatusAsync(GrantStatus status, bool includeDetails = false, CancellationToken cancellationToken = default); +} +``` + +## Domain Services + +- Naming: `*Manager` suffix (e.g., `GrantManager`). +- No interface by default unless multiple implementations are needed. +- Accept/return domain objects, not DTOs. +- Do NOT depend on authenticated user; accept required values from application layer. +- Use `GuidGenerator`, `Clock` from base class properties. + +```csharp +public class GrantManager : DomainService +{ + private readonly IGrantRepository _grantRepository; + + public GrantManager(IGrantRepository grantRepository) + { + _grantRepository = grantRepository; + } + + public async Task CreateAsync(string name) + { + var existing = await _grantRepository.FindByNameAsync(name); + if (existing != null) + throw new BusinessException("GrantManager:NameAlreadyExists").WithData("Name", name); + + return new Grant(GuidGenerator.Create(), name); + } +} +``` + +## Domain Events + +- `AddLocalEvent()` — same transaction, can access full entity state. +- `AddDistributedEvent()` — async, use ETOs defined in Domain.Shared. +- This project uses **RabbitMQ** for distributed events via `IDistributedEventBus`. + +## Shared Constants + +- Define constants, enums, and error codes in `*.Domain.Shared`. +- Localization resources (JSON) live under `Domain.Shared/Localization/*/en.json`. +- Error codes: namespaced as `ModuleName:ErrorCode`. diff --git a/applications/Unity.GrantManager/.github/skills/unity-ef-core/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-ef-core/SKILL.md new file mode 100644 index 0000000000..7743d295b9 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-ef-core/SKILL.md @@ -0,0 +1,112 @@ +--- +name: unity-ef-core +description: ABP Entity Framework Core for Unity - DbContext configuration, entity mapping, repository implementation, EF migrations. Use when working in EntityFrameworkCore projects, adding migrations, or implementing repositories. +--- + +# Unity EF Core Patterns + +> This project uses EF Core 9.0 with PostgreSQL 17 (Npgsql). Tests use SQLite in-memory. + +## Database Contexts + +This project has **two distinct database contexts**: + +| Context | Purpose | Migrations Directory | +|---------|---------|---------------------| +| `GrantManagerDbContext` | Host/shared system tables | `Migrations/HostMigrations` | +| `GrantTenantDbContext` | Per-tenant isolated data | `Migrations/TenantMigrations` | + +Always specify the context when adding migrations: + +```bash +cd src/Unity.GrantManager.EntityFrameworkCore + +# Host migration +dotnet ef migrations add --context GrantManagerDbContext --output-dir Migrations/HostMigrations + +# Tenant migration +dotnet ef migrations add --context GrantTenantDbContext --output-dir Migrations/TenantMigrations +``` + +## Entity Configuration + +Entity mapping is done via extension methods on `ModelBuilder`, NOT inline in `OnModelCreating`. + +```csharp +public static class GrantManagerDbContextModelCreatingExtensions +{ + public static void ConfigureGrantManager(this ModelBuilder builder) + { + Check.NotNull(builder, nameof(builder)); + + builder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "Grants", GrantManagerConsts.DbSchema); + b.ConfigureByConvention(); // Always call this first + + b.Property(x => x.Name) + .IsRequired() + .HasMaxLength(GrantConsts.MaxNameLength); + + b.HasIndex(x => x.Name); + }); + } +} +``` + +**Rules:** +- Always call `b.ConfigureByConvention()` for every entity. +- Use table prefix from constants (not hardcoded). +- Default schema should be `null`. + +## Repository Implementation + +```csharp +public class GrantRepository : EfCoreRepository, IGrantRepository +{ + public GrantRepository(IDbContextProvider dbContextProvider) + : base(dbContextProvider) { } + + public async Task FindByNameAsync( + string name, + bool includeDetails = true, + CancellationToken cancellationToken = default) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .IncludeDetails(includeDetails) + .FirstOrDefaultAsync(g => g.Name == name, GetCancellationToken(cancellationToken)); + } +} +``` + +- Use DbContext interface as generic parameter. +- Pass cancellation tokens via `GetCancellationToken(cancellationToken)`. +- Use `IncludeDetails()` extensions per aggregate root. + +## Module Registration + +```csharp +context.Services.AddAbpDbContext(options => +{ + options.AddDefaultRepositories(); // Aggregate roots only, NOT includeAllEntities: true +}); + +Configure(options => +{ + options.UseNpgsql(); // PostgreSQL +}); +``` + +## Never Do + +| Don't | Do Instead | +|-------|-----------| +| `AddDefaultRepositories(includeAllEntities: true)` | `AddDefaultRepositories()` — aggregate roots only | +| Skip `ConfigureByConvention()` | Always call it first in entity config | +| Inject DbContext in app/domain services | Use `IRepository` or custom repository interface | +| Use lazy loading | Explicit `.Include()` via `IncludeDetails()` | + +## Migrations .editorconfig + +The `Migrations/` folder has its own `.editorconfig` suppressing analyzer warnings (S1128, S1192, CS8981, CA1861, IDE naming rules). This is intentional — do not modify migration files for style. diff --git a/applications/Unity.GrantManager/.github/skills/unity-module-structure/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-module-structure/SKILL.md new file mode 100644 index 0000000000..68ed0f8091 --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-module-structure/SKILL.md @@ -0,0 +1,113 @@ +--- +name: unity-module-structure +description: ABP module architecture and layering rules for Unity. Use when creating new modules, adding cross-module dependencies, or understanding project organization and dependency direction. +--- + +# Unity Module Architecture + +## Module Layout + +Each ABP module follows a standard layered structure under `modules/`: + +``` +Unity.{ModuleName}/ + src/ + Unity.{ModuleName}.Domain.Shared/ ← Enums, constants, localization, ETOs + Unity.{ModuleName}.Domain/ ← Entities, repository interfaces, domain services + Unity.{ModuleName}.Application.Contracts/ ← DTOs, app service interfaces + Unity.{ModuleName}.Application/ ← App service implementations, AutoMapper profiles + Unity.{ModuleName}.EntityFrameworkCore/ ← DbContext, migrations (if module has own DB tables) + Unity.{ModuleName}.HttpApi/ ← REST controllers + Unity.{ModuleName}.HttpApi.Client/ ← Remote client proxies + Unity.{ModuleName}.Web/ ← Razor Pages, view components + test/ + Unity.{ModuleName}.TestBase/ + Unity.{ModuleName}.Application.Tests/ + Unity.{ModuleName}.Domain.Tests/ + Unity.{ModuleName}.EntityFrameworkCore.Tests/ +``` + +Not all modules have every layer. Simpler modules may only have `Application`, `Application.Contracts`, `Shared`, and `Web`. + +## Current Modules + +| Module | Layers Present | Purpose | +|--------|---------------|---------| +| **Unity.Flex** | Shared, App.Contracts, App, Web, Tests | Dynamic forms/worksheets | +| **Unity.Notifications** | Full stack (Domain→Web, HttpApi, EF) | Email/messaging | +| **Unity.Payments** | Shared, App.Contracts, App, Web, Tests | Financial transactions | +| **Unity.Reporting** | Shared, App.Contracts, App, Web, Tests | Analytics & reports | +| **Unity.AI** | Shared, App.Contracts, App, Web | AI analysis (OpenAI) | +| **Unity.TenantManagement** | App.Contracts, App, HttpApi, Web, Tests | Multi-tenant admin | +| **Unity.Identity.Web** | Web, Tests | OIDC authentication UI | +| **Unity.Theme.UX2** | Theme package, Tests | Custom Razor Pages theme | +| **Unity.SharedKernel** | Single project | Cross-cutting utilities | + +## Dependency Direction (Strict) + +``` +Web → HttpApi → Application.Contracts +Application → Domain + Application.Contracts +Domain → Domain.Shared +EntityFrameworkCore → Domain only +``` + +### Rules + +- Web/HttpApi must NEVER depend on Application (only Application.Contracts). +- Application must NEVER depend on Web or EF Core. +- Domain must NEVER depend on Application, Web, or EF Core. +- Domain.Shared must have NO dependencies on other layers. +- EF Core must ONLY depend on Domain. + +## ABP Module Classes + +Every package has exactly one `AbpModule` class with `[DependsOn]` attributes. + +```csharp +[DependsOn( + typeof(GrantManagerDomainModule), + typeof(AbpEntityFrameworkCoreModule) +)] +public class GrantManagerEntityFrameworkCoreModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddAbpDbContext(options => + { + options.AddDefaultRepositories(); + }); + } +} +``` + +## Multi-Tenancy + +- The system uses ABP multi-tenancy with separate database per tenant. +- `GrantManagerDbContext` = host context, `GrantTenantDbContext` = tenant context. +- Tenant-scoped data is accessed via `ICurrentTenant` / tenant switching. +- The `Unity.TenantManagement` module handles tenant administration. + +## Adding a New Feature + +1. Identify which module the feature belongs to. +2. Add entities/repositories in Domain layer. +3. Add DTOs/interfaces in Application.Contracts. +4. Implement app services in Application. +5. Add EF Core configuration if new tables are needed. +6. Add UI in Web layer. +7. Add tests in the module's test projects. +8. Register the module class with `[DependsOn]`. +9. Run `dotnet build Unity.GrantManager.sln` and `dotnet test Unity.GrantManager.sln` to verify. + +## Localization + +Each module with Domain.Shared has its own localization under: +`src/Unity.{ModuleName}.Domain.Shared/Localization/{ModuleName}/en.json` + +Use `L["Key"]` in application services and pages. All user-facing text must be localized. + +## Permissions + +Define in `*PermissionDefinitionProvider` in Application.Contracts. +Permission names follow `{ModuleName}.{Resource}.{Action}` convention. diff --git a/applications/Unity.GrantManager/.github/skills/unity-testing/SKILL.md b/applications/Unity.GrantManager/.github/skills/unity-testing/SKILL.md new file mode 100644 index 0000000000..eed37f3f5f --- /dev/null +++ b/applications/Unity.GrantManager/.github/skills/unity-testing/SKILL.md @@ -0,0 +1,155 @@ +--- +name: unity-testing +description: Testing patterns for Unity - xUnit, Shouldly assertions, NSubstitute mocks, ABP test infrastructure. Use when writing or modifying unit tests or integration tests. +--- + +# Unity Testing Patterns + +## Test Infrastructure + +| Aspect | Value | +|--------|-------| +| Framework | xUnit 2.9.3 | +| Assertions | Shouldly 4.3.0 | +| Mocking | NSubstitute 5.3.0 | +| Database | In-memory (SQLite for most projects; EFCore.InMemory for Web tests – no PostgreSQL required) | +| Base Classes | ABP `AbpIntegratedTest` | +| Target | .NET 9.0 | + +## Test Project Locations + +``` +test/ + Unity.GrantManager.TestBase/ ← Shared fixtures & test data + Unity.GrantManager.Application.Tests/ ← App service tests + Unity.GrantManager.Domain.Tests/ ← Domain logic tests + Unity.GrantManager.EntityFrameworkCore.Tests/ + Unity.GrantManager.Web.Tests/ +modules/Unity.*/test/ ← Each module has its own test projects +``` + +## Running Tests + +```bash +# All tests (~470 tests, ~2 min) +dotnet test Unity.GrantManager.sln + +# Single project +dotnet test test/Unity.GrantManager.Application.Tests/ + +# After build (faster) +dotnet test Unity.GrantManager.sln --no-build +``` + +## Base Class Hierarchy + +``` +AbpIntegratedTest (Volo.Abp.Testing) +└── GrantManagerTestBase (shared UoW helpers) + ├── GrantManagerDomainTestBase (domain tests) + ├── GrantManagerEntityFrameworkCoreTestBase + └── Module-specific bases: + ├── FlexTestBaseModule + ├── TenantManagementTestBase + └── ReportingTestBase +``` + +## Writing Tests + +### Unit Test Example (with mocking) + +```csharp +public class MyServiceTests +{ + private readonly IMyRepository _repository; + private readonly MyService _sut; + + public MyServiceTests() + { + _repository = Substitute.For(); + _sut = new MyService(_repository); + } + + [Fact] + public async Task CreateAsync_WithValidInput_ShouldSucceed() + { + // Arrange + _repository.FindByNameAsync(Arg.Any()).Returns((MyEntity?)null); + + // Act + var result = await _sut.CreateAsync("test"); + + // Assert + result.ShouldNotBeNull(); + result.Name.ShouldBe("test"); + } +} +``` + +### Integration Test Example (ABP) + +```csharp +public class GrantAppServiceTests : GrantManagerApplicationTestBase +{ + private readonly IGrantAppService _grantAppService; + + public GrantAppServiceTests() + { + _grantAppService = GetRequiredService(); + } + + [Fact] + public async Task Should_Get_Grant_By_Id() + { + var result = await _grantAppService.GetAsync(GrantManagerTestData.GrantId); + result.ShouldNotBeNull(); + result.Id.ShouldBe(GrantManagerTestData.GrantId); + } +} +``` + +### Parameterized Tests + +```csharp +[Theory] +[InlineData("schema1.json", 128)] +[InlineData("schema2.json", 10)] +public void TestMapping(string filename, int expectedCount) +{ + var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestData", filename); + var json = File.ReadAllText(path); + var result = Parse(json); + result.Count.ShouldBe(expectedCount); +} +``` + +## Test Data + +- JSON fixtures are loaded from `AppDomain.CurrentDomain.BaseDirectory` subdirectories. +- Domain tests include JSON files in `Intake/Files/*.json` and `Intake/Mapping/*.json` (copied to output via `.csproj`). +- Shared test data constants live in `*TestData.cs` classes within TestBase projects. + +## Web Tests + +Web tests use `[Collection]` fixture pattern: + +```csharp +[Collection(WebTestCollection.Name)] +public class MyWidgetTests +{ + private readonly IAbpLazyServiceProvider _lazyServiceProvider; + + public MyWidgetTests(WebTestFixture fixture) + { + _lazyServiceProvider = fixture.Services.GetRequiredService(); + } +} +``` + +## Conventions + +- Test class naming: `*Tests.cs` +- Method naming: `Should_ExpectedBehavior_When_Condition` or `MethodName_Scenario_ExpectedResult` +- Always use `Shouldly` for assertions (not `Assert.Equal`) +- Always use `NSubstitute` for mocking (not Moq) +- Test runner config: `xunit.runner.json` with `"shadowCopy": false` diff --git a/applications/Unity.GrantManager/.vscode/settings.json b/applications/Unity.GrantManager/.vscode/settings.json new file mode 100644 index 0000000000..ccc17ff07a --- /dev/null +++ b/applications/Unity.GrantManager/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "file": ".github/commit-message-instructions.md" + } + ] +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/Unity.GrantManager.sln b/applications/Unity.GrantManager/Unity.GrantManager.sln index 567d2f71c0..9fa0d5294b 100644 --- a/applications/Unity.GrantManager/Unity.GrantManager.sln +++ b/applications/Unity.GrantManager/Unity.GrantManager.sln @@ -153,7 +153,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.Shared", "mo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.Web.Tests", "modules\Unity.Flex\test\Unity.Flex.Web.Tests\Unity.Flex.Web.Tests.csproj", "{5F4CFB7E-A14A-40A1-8833-A55CB296D31B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Reporting.Web", "modules\Unity.Reporting\src\Unity.Reporting.Web\Unity.Reporting.Web.csproj", "{3E4E5506-9820-4650-8062-4A07FB2C851A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.Web", "modules\Unity.Reporting\src\Unity.Reporting.Web\Unity.Reporting.Web.csproj", "{3E4E5506-9820-4650-8062-4A07FB2C851A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Unity.AI", "Unity.AI", "{BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Shared", "modules\Unity.AI\src\Unity.AI.Domain.Shared\Unity.AI.Shared.csproj", "{7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Application.Contracts", "modules\Unity.AI\src\Unity.AI.Application.Contracts\Unity.AI.Application.Contracts.csproj", "{3ACF64C1-492A-4BE6-B270-0F755C65F30B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Application", "modules\Unity.AI\src\Unity.AI.Application\Unity.AI.Application.csproj", "{7CF9D364-2018-4199-879B-371F6E1AC58B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Web", "modules\Unity.AI\src\Unity.AI.Web\Unity.AI.Web.csproj", "{378A4EB8-3DC1-420E-98B5-798DE71BEF0D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -397,6 +407,22 @@ Global {3E4E5506-9820-4650-8062-4A07FB2C851A}.Debug|Any CPU.Build.0 = Debug|Any CPU {3E4E5506-9820-4650-8062-4A07FB2C851A}.Release|Any CPU.ActiveCfg = Release|Any CPU {3E4E5506-9820-4650-8062-4A07FB2C851A}.Release|Any CPU.Build.0 = Release|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A}.Release|Any CPU.Build.0 = Release|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3ACF64C1-492A-4BE6-B270-0F755C65F30B}.Release|Any CPU.Build.0 = Release|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7CF9D364-2018-4199-879B-371F6E1AC58B}.Release|Any CPU.Build.0 = Release|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -475,6 +501,11 @@ Global {0355D299-4880-4F11-84A9-E14639A76AC4} = {DC64FA90-4E98-442F-BBA9-116940A928CF} {5F4CFB7E-A14A-40A1-8833-A55CB296D31B} = {CDE485CC-D6EA-457A-88D6-DEEAF7CAC424} {3E4E5506-9820-4650-8062-4A07FB2C851A} = {FF8024E0-68D2-4716-8812-E6D16730F4CC} + {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} = {00099710-CF66-4BD2-932C-5B7534B78185} + {7C0E6C61-0903-4D48-B9ED-FFE08C4D420A} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} + {3ACF64C1-492A-4BE6-B270-0F755C65F30B} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} + {7CF9D364-2018-4199-879B-371F6E1AC58B} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} + {378A4EB8-3DC1-420E-98B5-798DE71BEF0D} = {BA2040C4-DC9D-44D2-B8A8-5A18D3D649AB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {28315BFD-90E7-4E14-A2EA-F3D23AF4126F} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/.gitattributes b/applications/Unity.GrantManager/modules/Unity.AI/.gitattributes new file mode 100644 index 0000000000..c941e52669 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/.gitattributes @@ -0,0 +1 @@ +**/wwwroot/libs/** linguist-vendored diff --git a/applications/Unity.GrantManager/modules/Unity.AI/.gitignore b/applications/Unity.GrantManager/modules/Unity.AI/.gitignore new file mode 100644 index 0000000000..e278eb5d95 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/.gitignore @@ -0,0 +1,262 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# Reporting +host/Unity.Reporting.AuthServer/Logs/logs.txt +host/Unity.Reporting.HttpApi.Host/Logs/logs.txt +host/Unity.Reporting.Web.Host/Logs/logs.txt +host/Unity.Reporting.Web.Unified/Logs/logs.txt +host/Unity.Reporting.Blazor.Server.Host/Logs/logs.txt + +# Use abp install-libs to restore. +**/wwwroot/libs/* diff --git a/applications/Unity.GrantManager/modules/Unity.AI/.prettierrc b/applications/Unity.GrantManager/modules/Unity.AI/.prettierrc new file mode 100644 index 0000000000..56af76bd94 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "useTabs": false, + "tabWidth": 4 +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/NuGet.Config b/applications/Unity.GrantManager/modules/Unity.AI/NuGet.Config new file mode 100644 index 0000000000..bdc451971a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/NuGet.Config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/common.props b/applications/Unity.GrantManager/modules/Unity.AI/common.props new file mode 100644 index 0000000000..87cf88dc65 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/common.props @@ -0,0 +1,24 @@ + + + latest + 0.1.0 + $(NoWarn);CS1591 + module + + + + + + All + runtime; build; native; contentfiles; analyzers + + + + + + + $(NoWarn);0436 + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs new file mode 100644 index 0000000000..c9c5d44334 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs @@ -0,0 +1,17 @@ +using Volo.Abp.Application; +using Volo.Abp.Modularity; +using Volo.Abp.Authorization; +using Volo.Abp.SettingManagement; + +namespace Unity.AI; + +[DependsOn( + typeof(AIDomainSharedModule), + typeof(AbpDddApplicationContractsModule), + typeof(AbpAuthorizationModule), + typeof(AbpSettingManagementApplicationContractsModule) + )] +public class AIApplicationContractsModule : AbpModule +{ + +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIRemoteServiceConsts.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIRemoteServiceConsts.cs new file mode 100644 index 0000000000..118f068b39 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIRemoteServiceConsts.cs @@ -0,0 +1,7 @@ +namespace Unity.AI; + +public static class AIRemoteServiceConsts +{ + public const string RemoteServiceName = "AI"; + public const string ModuleName = "ai"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs new file mode 100644 index 0000000000..fb7980f037 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -0,0 +1,51 @@ +using Unity.AI.Localization; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Localization; +using Volo.Abp.Features; +using Volo.Abp.SettingManagement; + +namespace Unity.AI.Permissions; + +public class AIPermissionDefinitionProvider : PermissionDefinitionProvider +{ + public override void Define(IPermissionDefinitionContext context) + { + // AI Permission Group + var aiPermissionsGroup = context.AddGroup( + AIPermissions.GroupName, + L("Permission:AI")); + + + aiPermissionsGroup.AddPermission( + AIPermissions.Reporting.ReportingDefault, + L("Permission:AI.Reporting")) + .RequireFeatures("Unity.AIReporting"); + + aiPermissionsGroup.AddPermission( + AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault, + L("Permission:AI.ApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); + + aiPermissionsGroup.AddPermission( + AIPermissions.AttachmentSummary.AttachmentSummaryDefault , + L("Permission:AI.AttachmentSummary")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); + + aiPermissionsGroup.AddPermission( + AIPermissions.ScoringAssistant.ScoringAssistantDefault, + L("Permission:AI.ScoringAssistant")) + .RequireFeatures("Unity.AI.Scoring"); + + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); + settingManagement.AddPermission( + AIPermissions.Configuration.ConfigureAI, + L("Permission:AI.ConfigureAI")) + .RequireFeatures("Unity.AI.Scoring"); + + } + + private static LocalizableString L(string name) + { + return LocalizableString.Create(name); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs new file mode 100644 index 0000000000..8caef403c4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -0,0 +1,40 @@ +using Volo.Abp.Reflection; + +namespace Unity.AI.Permissions; + +public static class AIPermissions +{ + public const string GroupName = "AI"; + + public const string Management = GroupName + ".Management"; + + public static class Reporting + { + public const string ReportingDefault = GroupName + ".Reporting"; + } + + public static class ApplicationAnalysis + { + public const string ApplicationAnalysisDefault = GroupName + ".ApplicationAnalysis"; + } + + public static class AttachmentSummary + { + public const string AttachmentSummaryDefault = GroupName + ".AttachmentSummary"; + } + + public static class ScoringAssistant + { + public const string ScoringAssistantDefault = GroupName + ".ScoringAssistant"; + } + + public static class Configuration + { + public const string ConfigureAI = "SettingManagement.ConfigureAI"; + } + + public static string[] GetAll() + { + return ReflectionHelper.GetPublicConstantsRecursively(typeof(AIPermissions)); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs new file mode 100644 index 0000000000..0bdff63a08 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using Volo.Abp.Application.Dtos; + +namespace Unity.AI.Prompts; + +public class AIPromptDto : AuditedEntityDto +{ + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public PromptType Type { get; set; } + public bool IsActive { get; set; } + public List Versions { get; set; } = new(); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs new file mode 100644 index 0000000000..a9ed4e50a8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs @@ -0,0 +1,20 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Unity.AI.Prompts; + +public class AIPromptVersionDto : AuditedEntityDto +{ + public Guid PromptId { get; set; } + public int VersionNumber { get; set; } + public string SystemPrompt { get; set; } = string.Empty; + public string UserPromptTemplate { get; set; } = string.Empty; + public string? DeveloperNotes { get; set; } + public string? TargetModel { get; set; } + public string? TargetProvider { get; set; } + public double Temperature { get; set; } + public int? MaxTokens { get; set; } + public bool IsPublished { get; set; } + public bool IsDeprecated { get; set; } + public string? MetadataJson { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs new file mode 100644 index 0000000000..6d361fd3ba --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Unity.AI.Prompts; + +public class CreateUpdateAIPromptDto +{ + [Required] + [MaxLength(200)] + [DisplayName("PromptName")] + public string Name { get; set; } = string.Empty; + + [MaxLength(2000)] + [DisplayName("PromptDescription")] + public string? Description { get; set; } + + [DisplayName("PromptType")] + public PromptType Type { get; set; } + + [DisplayName("PromptIsActive")] + public bool IsActive { get; set; } = true; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs new file mode 100644 index 0000000000..8e3414943f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs @@ -0,0 +1,47 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Unity.AI.Prompts; + +public class CreateUpdateAIPromptVersionDto +{ + public Guid PromptId { get; set; } + + [DisplayName("VersionNumber")] + public int VersionNumber { get; set; } + + [Required] + [DisplayName("SystemPrompt")] + public string SystemPrompt { get; set; } = string.Empty; + + [Required] + [DisplayName("UserPromptTemplate")] + public string UserPromptTemplate { get; set; } = string.Empty; + + [DisplayName("DeveloperNotes")] + public string? DeveloperNotes { get; set; } + + [MaxLength(100)] + [DisplayName("TargetModel")] + public string? TargetModel { get; set; } + + [MaxLength(100)] + [DisplayName("TargetProvider")] + public string? TargetProvider { get; set; } + + [DisplayName("Temperature")] + public double Temperature { get; set; } = 0.2; + + [DisplayName("MaxTokens")] + public int? MaxTokens { get; set; } + + [DisplayName("IsPublished")] + public bool IsPublished { get; set; } + + [DisplayName("IsDeprecated")] + public bool IsDeprecated { get; set; } + + [DisplayName("MetadataJson")] + public string? MetadataJson { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs new file mode 100644 index 0000000000..c259996db0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.AI.Prompts; + +public interface IAIPromptAppService : ICrudAppService< + AIPromptDto, + Guid, + PagedAndSortedResultRequestDto, + CreateUpdateAIPromptDto> +{ +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs new file mode 100644 index 0000000000..269029e5ec --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs @@ -0,0 +1,14 @@ +using System; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.AI.Prompts; + +public interface IAIPromptVersionAppService : ICrudAppService< + AIPromptVersionDto, + Guid, + PagedAndSortedResultRequestDto, + CreateUpdateAIPromptVersionDto> +{ + System.Threading.Tasks.Task> GetByPromptAsync(Guid promptId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/AIScoringSettingsDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/AIScoringSettingsDto.cs new file mode 100644 index 0000000000..14837f27b4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/AIScoringSettingsDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Settings; + +public class AIScoringSettingsDto +{ + public bool ScoringAssistantEnabled { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/IAIConfigurationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/IAIConfigurationAppService.cs new file mode 100644 index 0000000000..8a21259aa6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/IAIConfigurationAppService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.AI.Settings; + +public interface IAIConfigurationAppService : IApplicationService +{ + Task GetScoringSettingsAsync(); + Task UpdateScoringSettingsAsync(UpdateAIScoringSettingsDto input); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/UpdateAIScoringSettingsDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/UpdateAIScoringSettingsDto.cs new file mode 100644 index 0000000000..a6323e4a34 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Settings/UpdateAIScoringSettingsDto.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Settings; + +public class UpdateAIScoringSettingsDto +{ + public bool ScoringAssistantEnabled { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj new file mode 100644 index 0000000000..dcf9c1f296 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj @@ -0,0 +1,27 @@ + + + + + + net9.0 + enable + Unity.AI + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIAppService.cs new file mode 100644 index 0000000000..ff3f4f8b25 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIAppService.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Application.Services; + +namespace Unity.AI; + +public abstract class AIAppService : ApplicationService +{ + protected AIAppService() + { + LocalizationResource = typeof(Localization.AIResource); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs new file mode 100644 index 0000000000..382fcee837 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs @@ -0,0 +1,17 @@ +using AutoMapper; +using Unity.AI.Domain; +using Unity.AI.Prompts; + +namespace Unity.AI; + +public class AIApplicationAutoMapperProfile : Profile +{ + public AIApplicationAutoMapperProfile() + { + CreateMap(); + CreateMap(MemberList.None); + + CreateMap(); + CreateMap(MemberList.None); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs new file mode 100644 index 0000000000..6c9b4cc5b6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.AutoMapper; +using Volo.Abp.Modularity; +using Volo.Abp.Application; +using Volo.Abp.MultiTenancy; +using Volo.Abp.VirtualFileSystem; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.TenantManagement; + +namespace Unity.AI; + +[DependsOn( + typeof(AIApplicationContractsModule), + typeof(AbpDddApplicationModule), + typeof(AbpAutoMapperModule), + typeof(AbpTenantManagementDomainModule) + )] +public class AIApplicationModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + PreConfigure(mvcBuilder => + { + mvcBuilder.AddApplicationPartIfNotExists(typeof(AIApplicationModule).Assembly); + }); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.IsEnabled = true; + }); + + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + context.Services.AddAutoMapperObjectMapper(); + Configure(options => + { + options.AddMaps(validate: true); + }); + + context.Services.AddHttpClientProxies( + typeof(AIApplicationContractsModule).Assembly, + AIRemoteServiceConsts.RemoteServiceName + ); + + Configure(options => + { + options.ConventionalControllers.Create(typeof(AIApplicationModule).Assembly); + }); + + context.Services.AddAssemblyOf(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs new file mode 100644 index 0000000000..dc0edc9a78 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -0,0 +1,607 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.AI.DataSeed; + +/// +/// Seeds the built-in AI prompts (analysis, attachment, scoresheet) into the host database. +/// Each prompt is seeded with two versions — v0 (original single-file prompts) and v1 (modular +/// prompts with separate rubric, score, output, and rules sections stored in MetadataJson). +/// The seeder is idempotent: it checks by fixed GUID before inserting. +/// +public class AIPromptDataSeeder( + IRepository promptRepository, + IRepository versionRepository, + ICurrentTenant currentTenant) : IDataSeedContributor, ITransientDependency +{ + // Fixed deterministic GUIDs — never change these; they ensure idempotent re-seeding + private static readonly Guid AnalysisPromptId = new("4a100001-1000-4000-a000-000000000001"); + private static readonly Guid AttachmentPromptId = new("4a100001-1000-4000-a000-000000000002"); + private static readonly Guid ScoresheetPromptId = new("4a100001-1000-4000-a000-000000000003"); + + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId != null) return; // host database only + + using (currentTenant.Change(null)) + { + await SeedAnalysisPromptAsync(); + await SeedAttachmentPromptAsync(); + await SeedScoresheetPromptAsync(); + } + } + + // ─── ANALYSIS ──────────────────────────────────────────────────────────── + + private async Task SeedAnalysisPromptAsync() + { + if (await promptRepository.AnyAsync(p => p.Id == AnalysisPromptId)) return; + + await promptRepository.InsertAsync(new AIPrompt(AnalysisPromptId, "analysis", PromptType.Skill) + { + Description = "Grant application analysis and review", + IsActive = true + }); + + await versionRepository.InsertAsync(new AIPromptVersion( + Guid.CreateVersion7(), AnalysisPromptId, 0, + AnalysisSystemV0, AnalysisUserV0) + { + DeveloperNotes = "v0 — initial single-file analysis prompt", + IsPublished = true + }); + + await versionRepository.InsertAsync(new AIPromptVersion( + Guid.CreateVersion7(), AnalysisPromptId, 1, + AnalysisSystemV1, AnalysisUserV1) + { + DeveloperNotes = "v1 — modular prompt with separate rubric, score, output, and rules sections", + IsPublished = true, + MetadataJson = BuildSections( + rubric: AnalysisRubric, + score: AnalysisScore, + output: AnalysisOutput, + rules: AnalysisRules, + commonRules: CommonRules) + }); + } + + // ─── ATTACHMENT ─────────────────────────────────────────────────────────── + + private async Task SeedAttachmentPromptAsync() + { + if (await promptRepository.AnyAsync(p => p.Id == AttachmentPromptId)) return; + + await promptRepository.InsertAsync(new AIPrompt(AttachmentPromptId, "attachment", PromptType.Skill) + { + Description = "Attachment summarization for grant review", + IsActive = true + }); + + await versionRepository.InsertAsync(new AIPromptVersion( + Guid.CreateVersion7(), AttachmentPromptId, 0, + AttachmentSystemV0, AttachmentUserV0) + { + DeveloperNotes = "v0 — initial single-file attachment prompt", + IsPublished = true + }); + + await versionRepository.InsertAsync(new AIPromptVersion( + Guid.CreateVersion7(), AttachmentPromptId, 1, + AttachmentSystemV1, AttachmentUserV1) + { + DeveloperNotes = "v1 — modular prompt with separate output and rules sections", + IsPublished = true, + MetadataJson = BuildSections( + output: AttachmentOutput, + rules: AttachmentRules, + commonRules: CommonRules) + }); + } + + // ─── SCORESHEET ─────────────────────────────────────────────────────────── + + private async Task SeedScoresheetPromptAsync() + { + if (await promptRepository.AnyAsync(p => p.Id == ScoresheetPromptId)) return; + + await promptRepository.InsertAsync(new AIPrompt(ScoresheetPromptId, "scoresheet", PromptType.Skill) + { + Description = "Scoresheet section answering assistant", + IsActive = true + }); + + await versionRepository.InsertAsync(new AIPromptVersion( + Guid.CreateVersion7(), ScoresheetPromptId, 0, + ScoresheetSystemV0, ScoresheetUserV0) + { + DeveloperNotes = "v0 — initial single-file scoresheet prompt", + IsPublished = true + }); + + await versionRepository.InsertAsync(new AIPromptVersion( + Guid.CreateVersion7(), ScoresheetPromptId, 1, + ScoresheetSystemV1, ScoresheetUserV1) + { + DeveloperNotes = "v1 — modular prompt with separate output and rules sections", + IsPublished = true, + MetadataJson = BuildSections( + output: ScoresheetOutput, + rules: ScoresheetRules, + commonRules: CommonRules) + }); + } + + // ─── HELPERS ────────────────────────────────────────────────────────────── + + private static string BuildSections( + string? rubric = null, string? score = null, + string? output = null, string? rules = null, string? commonRules = null) + { + var dict = new Dictionary(); + if (rubric != null) dict["RUBRIC"] = rubric; + if (score != null) dict["SCORE"] = score; + if (output != null) dict["OUTPUT"] = output; + if (rules != null) dict["RULES"] = rules; + if (commonRules != null) dict["COMMON_RULES"] = commonRules; + return JsonSerializer.Serialize(new { sections = dict }); + } + + // ═════════════════════════════════════════════════════════════════════════ + // PROMPT CONTENT — mirrors AI/Prompts/Versions/ text files verbatim + // ═════════════════════════════════════════════════════════════════════════ + + // ── v0/analysis.system.txt ─────────────────────────────────────────────── + private const string AnalysisSystemV0 = """ + You are an expert grant application reviewer for the BC Government. + + Conduct a thorough, comprehensive analysis across all rubric areas. Identify substantive issues, concerns, and opportunities for improvement. + + Classify findings by their effect on the application's quality and fundability: + - ERRORS: important missing information, significant gaps, compliance issues, or major concerns affecting eligibility + - WARNINGS: areas needing clarification, moderate issues, or concerns that should be addressed + - SUMMARIES: concise reviewer-facing recommendations or follow-up considerations + + Evaluate content quality, clarity, and appropriateness. Be thorough but fair and avoid nitpicking. + + Respond only with valid JSON in the exact format requested. + """; + + // ── v0/analysis.user.txt ───────────────────────────────────────────────── + private const string AnalysisUserV0 = """ + APPLICATION CONTENT: + {{DATA}} + + ATTACHMENT SUMMARIES: + {{ATTACHMENTS}} + + FORM FIELD CONFIGURATION: + {{SCHEMA}} + + MANDATORY FIELDS: + - Determine mandatory fields from FORM FIELD CONFIGURATION. + - Report missing mandatory fields as findings when they materially affect review quality. + + OPTIONAL FIELDS (may be left blank): + - Determine optional fields from FORM FIELD CONFIGURATION. + - Do not flag optional fields when blank unless they materially weaken rubric evidence. + + EVALUATION RUBRIC: + BC GOVERNMENT GRANT EVALUATION RUBRIC: + + 1. ELIGIBILITY REQUIREMENTS: + - Project must align with program objectives + - Applicant must be eligible entity type + - Budget must be reasonable and well-justified + - Project timeline must be realistic + + 2. COMPLETENESS CHECKS: + - All required fields completed + - Necessary supporting documents provided + - Budget breakdown detailed and accurate + - Project description clear and comprehensive + + 3. FINANCIAL REVIEW: + - Requested amount is within program limits + - Budget is reasonable for scope of work + - Matching funds or in-kind contributions identified + - Cost per outcome/beneficiary is reasonable + + 4. RISK ASSESSMENT: + - Applicant capacity to deliver project + - Technical feasibility of proposed work + - Environmental or regulatory compliance + - Potential for cost overruns or delays + + 5. QUALITY INDICATORS: + - Clear project objectives and outcomes + - Well-defined target audience/beneficiaries + - Appropriate project methodology + - Sustainability plan for long-term impact + + EVALUATION CRITERIA: + - HIGH: Meets all requirements, well-prepared application, low risk + - MEDIUM: Meets most requirements, minor issues or missing elements + - LOW: Missing key requirements, significant concerns, high risk + + Analyze this grant application comprehensively across all five rubric categories (Eligibility, Completeness, Financial Review, Risk Assessment, and Quality Indicators). Identify issues, concerns, and areas for improvement. + + OUTPUT + { + "rating": "", + "warnings": [ + { + "title": "", + "detail": "" + } + ], + "errors": [ + { + "title": "", + "detail": "" + } + ], + "summaries": [ + { + "title": "", + "detail": "" + } + ], + "nextSteps": [ + { + "title": "", + "detail": "" + } + ], + "recommendation": { + "decision": "", + "rationale": "" + } + } + + Important: + - Use only APPLICATION CONTENT, ATTACHMENT SUMMARIES, FORM FIELD CONFIGURATION, and EVALUATION RUBRIC as evidence. + - Use summaries for overall application quality/readiness synthesis. + - Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. + - recommendation.decision must be PROCEED or HOLD. + - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. + - Use "title" and "detail" keys for all finding objects. + - Return valid plain JSON only in the exact OUTPUT shape. + """; + + // ── v1/analysis.system.txt ─────────────────────────────────────────────── + private const string AnalysisSystemV1 = """ + ROLE + You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. + + TASK + Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: + 1. Review the application and attachments for the strongest reviewer-relevant evidence. + 2. Determine which conclusions are directly supported by that evidence. + 3. Exclude weak, repetitive, or loosely supported conclusions. + 4. Return only the strongest evidence-backed reviewer conclusions. + """; + + // ── v1/analysis.user.txt ───────────────────────────────────────────────── + private const string AnalysisUserV1 = """ + SCHEMA + {{SCHEMA}} + + DATA + {{DATA}} + + ATTACHMENTS + {{ATTACHMENTS}} + + RUBRIC + {{RUBRIC}} + + SCORE + {{SCORE}} + + OUTPUT + {{OUTPUT}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v1/analysis.rubric.txt ─────────────────────────────────────────────── + private const string AnalysisRubric = """ + ELIGIBILITY REQUIREMENTS: Project aligns with program objectives; Applicant is an eligible entity; Budget is reasonable and justified; Timeline is realistic. + COMPLETENESS CHECKS: Required information is present; Supporting materials are provided where applicable; Description is clear. + FINANCIAL REVIEW: Requested amount is within limits; Budget matches scope; Matching funds or contributions are identified. + RISK ASSESSMENT: Applicant capacity; Feasibility; Compliance considerations; Delivery risks. + QUALITY INDICATORS: Clear objectives; Defined beneficiaries; Appropriate approach; Long-term sustainability. + """; + + // ── v1/analysis.score.txt ──────────────────────────────────────────────── + private const string AnalysisScore = """ + HIGH: Application demonstrates strong evidence across most rubric areas with few or no issues. + MEDIUM: Application has some gaps or weaknesses that require reviewer attention. + LOW: Application has significant gaps or risks across key rubric areas. + """; + + // ── v1/analysis.output.txt ─────────────────────────────────────────────── + private const string AnalysisOutput = """ + { + "rating": "", + "errors": [ + { + "title": "", + "detail": "" + } + ], + "warnings": [ + { + "title": "", + "detail": "" + } + ], + "summaries": [ + { + "title": "", + "detail": "" + } + ], + "nextSteps": [ + { + "title": "", + "detail": "" + } + ], + "recommendation": { + "decision": "", + "rationale": "" + } + } + """; + + // ── v1/analysis.rules.txt ──────────────────────────────────────────────── + private const string AnalysisRules = """ + - Use only provided input sections as evidence. + - Do not invent fields, documents, requirements, or facts. + - Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. + - Treat missing or empty values as findings only when they weaken rubric evidence. + - Prefer material findings; avoid nitpicking. + - Do not restate basic application facts as findings unless they support a specific reviewer conclusion about readiness, feasibility, budget credibility, eligibility, or confidence in proceeding. + - Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. + - If ATTACHMENTS evidence is used, cite the attachment by name in detail. + - Each detail must cite concrete evidence from DATA or ATTACHMENTS. + - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. + - Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. + - Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. + - Do not state that one amount exceeds, matches, or conflicts with another unless the comparison is directly supported by the provided values. + - Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. + - Prefer neutral evidence descriptions over evaluative adjectives unless the evidence directly supports a strong conclusion. + - Do not describe capacity, feasibility, or justification as strong, detailed, or well-supported unless the evidence shows more than the existence of basic organizational, budget, or timeline information. + - Do not infer community support, established partnerships, or delivery capacity from a single partner reference, staff count, or basic organizational status alone. + - Do not describe a timeline as realistic or feasible based only on start and end dates unless additional evidence supports deliverability. + - Use 3-6 words for title. + - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. + - Each detail must be 1-2 complete sentences. + - Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. + - Avoid generic praise, checklist language, and repeated conclusions across lists. + - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. + - If no findings exist, return empty arrays. + - Rating must be HIGH, MEDIUM, or LOW. + - Use summaries for overall application quality/readiness synthesis. + - Use nextSteps for concrete reviewer-facing next actions based on the provided evidence. + - nextSteps may include proceeding with the normal review process when the application appears ready for that step. + - When evidence shows a meaningful gap, inconsistency, or uncertainty, use nextSteps for specific follow-up or verification actions. + - Return an empty array only when no concrete next action would help the reviewer. + - recommendation.decision must be PROCEED or HOLD. + - Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. + - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. + - recommendation.rationale should name the 1-3 strongest evidence-based reasons for the recommendation. + """; + + // ── v0/attachment.system.txt ───────────────────────────────────────────── + private const string AttachmentSystemV0 = """ + You are a professional grant analyst for the BC Government. + + Please analyze this attachment and provide a concise reviewer-facing summary of its content, purpose, and key information. + + OUTPUT + { + "summary": "" + } + + Use only ATTACHMENT as evidence. If ATTACHMENT.text is present, summarize actual content; otherwise provide a conservative file-level summary. Write 1-2 complete sentences and return valid plain JSON only in the exact OUTPUT shape. + """; + + // ── v0/attachment.user.txt ─────────────────────────────────────────────── + private const string AttachmentUserV0 = """ + ATTACHMENT + {{ATTACHMENT}} + """; + + // ── v1/attachment.system.txt ───────────────────────────────────────────── + private const string AttachmentSystemV1 = """ + ROLE + You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. + + TASK + Using ATTACHMENT, OUTPUT, and RULES: + 1. Review the attachment to identify what it contains. + 2. Summarize the attachment itself, not the overall project. + 3. Return a concise reviewer-facing summary. + """; + + // ── v1/attachment.user.txt ─────────────────────────────────────────────── + private const string AttachmentUserV1 = """ + ATTACHMENT + {{ATTACHMENT}} + + OUTPUT + {{OUTPUT}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v1/attachment.output.txt ───────────────────────────────────────────── + private const string AttachmentOutput = """ + { + "summary": "" + } + """; + + // ── v1/attachment.rules.txt ────────────────────────────────────────────── + private const string AttachmentRules = """ + - Use only ATTACHMENT as evidence. + - Summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary. + - Describe the attachment itself rather than summarizing the overall project. + - Ensure the summary describes the attachment itself, not the overall project. + - If ATTACHMENT.text is primarily structured application, contact, organization, budget, or date fields, summarize it as a metadata-style attachment rather than rewriting it as a generic project summary. + - Begin with what the attachment contains or provides, not the file name or file type, unless that metadata is necessary to describe the evidence. + - Do not invent missing details. + - Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. + - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as ATTACHMENT or ATTACHMENT.text. + - Refer to evidence by its plain-language meaning, quoted text, or file name rather than internal key names. + - Write 1-2 complete sentences. + - Summary must be grounded in concrete ATTACHMENT evidence. + - Return exactly one object with only the key: summary. + """; + + // ── v0/scoresheet.system.txt ───────────────────────────────────────────── + private const string ScoresheetSystemV0 = """ + You are an expert grant application reviewer for the BC Government. + Analyze the provided application and answer only the questions in the specified scoresheet section. + Be thorough, objective, and fair. Base answers strictly on provided evidence. + Always provide evidence-grounded rationale and an honest confidence score. + Respond only with valid JSON in the exact format requested. + """; + + // ── v0/scoresheet.user.txt ─────────────────────────────────────────────── + private const string ScoresheetUserV0 = """ + APPLICATION CONTENT: + {{DATA}} + + ATTACHMENT SUMMARIES: + {{ATTACHMENTS}} + + SCORESHEET SECTION: + {{SECTION}} + + RESPONSE TEMPLATE: + {{RESPONSE}} + + Please analyze this grant application and provide answers for each question in the specified section only. + + For each question, provide: + 1. The answer based on the application evidence + 2. A brief rationale (1-2 complete sentences) citing concrete supporting evidence + 3. A confidence score from 0-100 (integer) indicating certainty in the selected answer + + OUTPUT + { + "": { + "answer": "", + "rationale": "", + "confidence": + } + } + + Important: + - Use only APPLICATION CONTENT and ATTACHMENT SUMMARIES as evidence. + - Answer only the question IDs in the specified section. + - Every question must include answer, rationale, and confidence. + - Use RESPONSE TEMPLATE as the contract and fill every placeholder value. + - answer type must match the question type. + - For select list questions, return only the option number as a string, never label text. + - rationale must be 1-2 complete sentences grounded in evidence. + - confidence must be an integer from 0 to 100 in increments of 5. + - Return valid plain JSON only in the exact OUTPUT shape. + """; + + // ── v1/scoresheet.system.txt ───────────────────────────────────────────── + private const string ScoresheetSystemV1 = """ + ROLE + You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. + + TASK + Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: + 1. Review each question in SECTION one at a time. + 2. Identify the exact condition the question asks about. + 3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. + 4. Choose the most conservative valid answer supported by that evidence. + 5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. + 6. Repeat for every question in SECTION. + """; + + // ── v1/scoresheet.user.txt ─────────────────────────────────────────────── + private const string ScoresheetUserV1 = """ + DATA + {{DATA}} + + ATTACHMENTS + {{ATTACHMENTS}} + + SECTION + {{SECTION}} + + RESPONSE + {{RESPONSE}} + + OUTPUT + {{OUTPUT}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v1/scoresheet.output.txt ───────────────────────────────────────────── + private const string ScoresheetOutput = """ + { + "": { + "answer": "", + "rationale": "", + "confidence": + } + } + """; + + // ── v1/scoresheet.rules.txt ────────────────────────────────────────────── + private const string ScoresheetRules = """ + - Use only DATA and ATTACHMENTS as evidence. + - Do not invent missing application details. + - Ignore fields or details that are not relevant to the specific question being answered. + - Prefer, in order: direct evidence of the exact condition asked, closely related supporting evidence, then general context only when necessary. + - If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. + - Do not convert general project descriptions into evidence for a specific scored condition unless that condition is directly supported. + - Treat prefilled labels, ratings, rankings, or statuses as background context only unless the question explicitly asks for that same item. + - Do not treat related concepts as equivalent; answer the specific question asked, not a nearby concept. + - Do not infer unsupported claims about requirements, conditions, relationships, compliance elements, mitigations, supports, or outcomes. + - Answer a specific condition positively only when that exact condition is directly evidenced in DATA or ATTACHMENTS. + - For eligibility, completeness, ownership, location, or compliance questions, do not answer positively unless the exact condition is directly confirmed in the provided evidence. + - If the evidence shows only involvement, presence, relevance, or association, do not treat that alone as proof that a requirement or condition is satisfied. + - Return exactly one answer object per question ID in SECTION.questions. + - Do not omit any question IDs from SECTION.questions. + - Do not add keys that are not question IDs from SECTION.questions. + - Use the exact question IDs from RESPONSE and SECTION.questions without alteration; never rewrite, normalize, or regenerate a question ID. + - Use RESPONSE as the output contract and fill every placeholder value. + - Each answer object must include: "answer", "rationale", and "confidence". + - Never omit "answer", "rationale", or "confidence" for any question type. + - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. + """; + + // ── v1/common.rules.txt ────────────────────────────────────────────────── + private const string CommonRules = """ + - Any narrative text response must be at least 12 words. + - Return values exactly as specified in OUTPUT. + - Do not return keys outside OUTPUT. + - Return valid JSON only. + - Return plain JSON only (no markdown). + """; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs new file mode 100644 index 0000000000..d786709167 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs @@ -0,0 +1,11 @@ +namespace Unity.AI.Domain; + +public static class AIDbProperties +{ + public static string DbTablePrefix { get; set; } = string.Empty; + + /// + /// Schema for Unity.AI tables — kept separate from other modules. + /// + public static string? DbSchema { get; set; } = "AI"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs new file mode 100644 index 0000000000..8aeb74d3d2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.AI.Domain; + +public class AIPrompt : AuditedAggregateRoot, IMultiTenant +{ + public virtual Guid? TenantId { get; protected set; } + + public string Name { get; set; } = default!; + + public string? Description { get; set; } + + public PromptType Type { get; set; } + + public bool IsActive { get; set; } = true; + + public ICollection Versions { get; set; } = new List(); + + protected AIPrompt() { } + + public AIPrompt(Guid id, string name, PromptType type, Guid? tenantId = null) + { + Id = id; + Name = name; + Type = type; + TenantId = tenantId; + IsActive = true; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs new file mode 100644 index 0000000000..440aec6e0c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs @@ -0,0 +1,50 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.AI.Domain; + +public class AIPromptVersion : AuditedAggregateRoot, IMultiTenant +{ + public virtual Guid? TenantId { get; protected set; } + + public Guid PromptId { get; set; } + public AIPrompt? Prompt { get; set; } + + public int VersionNumber { get; set; } + + public string SystemPrompt { get; set; } = default!; + public string UserPromptTemplate { get; set; } = default!; + public string? DeveloperNotes { get; set; } + + public string? TargetModel { get; set; } + public string? TargetProvider { get; set; } + + public double Temperature { get; set; } = 0.2; + public int? MaxTokens { get; set; } + + public bool IsPublished { get; set; } + public bool IsDeprecated { get; set; } + + /// Optional JSON metadata for extensibility (stored as Postgres jsonb). + public string? MetadataJson { get; set; } + + protected AIPromptVersion() { } + + public AIPromptVersion( + Guid id, + Guid promptId, + int versionNumber, + string systemPrompt, + string userPromptTemplate, + Guid? tenantId = null) + { + Id = id; + PromptId = promptId; + VersionNumber = versionNumber; + SystemPrompt = systemPrompt; + UserPromptTemplate = userPromptTemplate; + TenantId = tenantId; + Temperature = 0.2; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs new file mode 100644 index 0000000000..94ccefb672 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs @@ -0,0 +1,58 @@ +using Microsoft.EntityFrameworkCore; +using Unity.AI.Domain; +using Volo.Abp; +using Volo.Abp.EntityFrameworkCore.Modeling; + +namespace Unity.AI.EntityFrameworkCore; + +public static class AIDbContextModelCreatingExtensions +{ + public static void ConfigureAI(this ModelBuilder modelBuilder) + { + Check.NotNull(modelBuilder, nameof(modelBuilder)); + + modelBuilder.Entity(b => + { + b.ToTable(AIDbProperties.DbTablePrefix + "AIPrompts", AIDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Name) + .IsRequired() + .HasMaxLength(200); + + b.Property(x => x.Description) + .HasMaxLength(2000); + + b.Property(x => x.Type) + .IsRequired(); + + b.HasMany(x => x.Versions) + .WithOne(x => x.Prompt) + .HasForeignKey(x => x.PromptId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(b => + { + b.ToTable(AIDbProperties.DbTablePrefix + "AIPromptVersions", AIDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.SystemPrompt).IsRequired().HasColumnType("text"); + b.Property(x => x.UserPromptTemplate).IsRequired().HasColumnType("text"); + + b.Property(x => x.TargetModel) + .HasMaxLength(100); + + b.Property(x => x.TargetProvider) + .HasMaxLength(100); + + b.Property(x => x.MetadataJson) + .HasColumnType("jsonb"); + + b.HasIndex(x => new { x.PromptId, x.VersionNumber }) + .IsUnique(); + }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs new file mode 100644 index 0000000000..5167d1b136 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Authorization; +using System; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.Modules.Shared.Permissions; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; + +namespace Unity.AI.Prompts; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class AIPromptAppService : + CrudAppService< + AIPrompt, + AIPromptDto, + Guid, + PagedAndSortedResultRequestDto, + CreateUpdateAIPromptDto>, + IAIPromptAppService +{ + public AIPromptAppService(IRepository repository) + : base(repository) + { + GetPolicyName = IdentityConsts.ITOperationsPolicyName; + GetListPolicyName = IdentityConsts.ITOperationsPolicyName; + CreatePolicyName = IdentityConsts.ITOperationsPolicyName; + UpdatePolicyName = IdentityConsts.ITOperationsPolicyName; + DeletePolicyName = IdentityConsts.ITOperationsPolicyName; + } + + public override async Task GetAsync(Guid id) + { + using (CurrentTenant.Change(null)) + { + return await base.GetAsync(id); + } + } + + public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) + { + using (CurrentTenant.Change(null)) + { + return await base.GetListAsync(input); + } + } + + public override async Task CreateAsync(CreateUpdateAIPromptDto input) + { + using (CurrentTenant.Change(null)) + { + return await base.CreateAsync(input); + } + } + + public override async Task UpdateAsync(Guid id, CreateUpdateAIPromptDto input) + { + using (CurrentTenant.Change(null)) + { + return await base.UpdateAsync(id, input); + } + } + + public override async Task DeleteAsync(Guid id) + { + using (CurrentTenant.Change(null)) + { + await base.DeleteAsync(id); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs new file mode 100644 index 0000000000..a44218d410 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Authorization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.Modules.Shared.Permissions; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; + +namespace Unity.AI.Prompts; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class AIPromptVersionAppService : + CrudAppService< + AIPromptVersion, + AIPromptVersionDto, + Guid, + PagedAndSortedResultRequestDto, + CreateUpdateAIPromptVersionDto>, + IAIPromptVersionAppService +{ + public AIPromptVersionAppService(IRepository repository) + : base(repository) + { + GetPolicyName = IdentityConsts.ITOperationsPolicyName; + GetListPolicyName = IdentityConsts.ITOperationsPolicyName; + CreatePolicyName = IdentityConsts.ITOperationsPolicyName; + UpdatePolicyName = IdentityConsts.ITOperationsPolicyName; + DeletePolicyName = IdentityConsts.ITOperationsPolicyName; + } + + public async Task> GetByPromptAsync(Guid promptId) + { + using (CurrentTenant.Change(null)) + { + var items = await Repository.GetListAsync(v => v.PromptId == promptId); + var sorted = items.OrderBy(v => v.VersionNumber).ToList(); + return new ListResultDto( + ObjectMapper.Map, List>(sorted)); + } + } + + public override async Task GetAsync(Guid id) + { + using (CurrentTenant.Change(null)) + { + return await base.GetAsync(id); + } + } + + public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) + { + using (CurrentTenant.Change(null)) + { + return await base.GetListAsync(input); + } + } + + public override async Task CreateAsync(CreateUpdateAIPromptVersionDto input) + { + using (CurrentTenant.Change(null)) + { + return await base.CreateAsync(input); + } + } + + public override async Task UpdateAsync(Guid id, CreateUpdateAIPromptVersionDto input) + { + using (CurrentTenant.Change(null)) + { + return await base.UpdateAsync(id, input); + } + } + + public override async Task DeleteAsync(Guid id) + { + using (CurrentTenant.Change(null)) + { + await base.DeleteAsync(id); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs new file mode 100644 index 0000000000..af5dfd02e7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.AI.Permissions; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; + +namespace Unity.AI.Settings; + +public class AIConfigurationAppService : AIAppService, IAIConfigurationAppService +{ + private readonly ISettingProvider _settingProvider; + private readonly ISettingManager _settingManager; + private readonly ICurrentTenant _currentTenant; + + public AIConfigurationAppService( + ISettingProvider settingProvider, + ISettingManager settingManager, + ICurrentTenant currentTenant) + { + _settingProvider = settingProvider; + _settingManager = settingManager; + _currentTenant = currentTenant; + } + + public virtual async Task GetScoringSettingsAsync() + { + return new AIScoringSettingsDto + { + ScoringAssistantEnabled = await _settingProvider.GetAsync( + AISettings.ScoringAssistantEnabled, defaultValue: false) + }; + } + + [Authorize(AIPermissions.Configuration.ConfigureAI)] + public virtual async Task UpdateScoringSettingsAsync(UpdateAIScoringSettingsDto input) + { + await _settingManager.SetAsync( + AISettings.ScoringAssistantEnabled, + input.ScoringAssistantEnabled.ToString().ToLowerInvariant(), + TenantSettingValueProvider.ProviderName, + _currentTenant.Id?.ToString()); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AISettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AISettingDefinitionProvider.cs new file mode 100644 index 0000000000..5032d042af --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AISettingDefinitionProvider.cs @@ -0,0 +1,27 @@ +using Unity.AI.Localization; +using Volo.Abp.Localization; +using Volo.Abp.Settings; + +namespace Unity.AI.Settings; + +public class AISettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + AISettings.ScoringAssistantEnabled, + "false", + L("Setting:AI.ScoringAssistantEnabled"), + isVisibleToClients: false, + isInherited: false, + isEncrypted: false) + .WithProviders(TenantSettingValueProvider.ProviderName) + ); + } + + private static LocalizableString L(string name) + { + return LocalizableString.Create(name); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj new file mode 100644 index 0000000000..e3be378d9b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj @@ -0,0 +1,31 @@ + + + + + + net9.0 + enable + Unity.AI + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AISharedModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AISharedModule.cs new file mode 100644 index 0000000000..c33c032428 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AISharedModule.cs @@ -0,0 +1,40 @@ +using Volo.Abp.Modularity; +using Volo.Abp.Localization; +using Unity.AI.Localization; +using Volo.Abp.Domain; +using Volo.Abp.Localization.ExceptionHandling; +using Volo.Abp.Validation; +using Volo.Abp.Validation.Localization; +using Volo.Abp.VirtualFileSystem; +using Volo.Abp.Settings; + +namespace Unity.AI; + +[DependsOn( + typeof(AbpValidationModule), + typeof(AbpDddDomainSharedModule), + typeof(AbpSettingsModule) +)] +public class AIDomainSharedModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + Configure(options => + { + options.Resources + .Add("en") + .AddBaseTypes(typeof(AbpValidationResource)) + .AddVirtualJson("/Localization/AI"); + }); + + Configure(options => + { + options.MapCodeNamespace("AI", typeof(AIResource)); + }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json new file mode 100644 index 0000000000..d6b84d6ca1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -0,0 +1,34 @@ +{ + "culture": "en", + "texts": { + "Permission:AI": "AI", + "Permission:AI.Reporting": "AI Reporting", + "Permission:AI.ApplicationAnalysis": "AI Application Analysis", + "Permission:AI.AttachmentSummary": "AI Attachment Summary", + "Permission:AI.ScoringAssistant": "AI Scoring Assistant", + "Setting:AI.ScoringAssistantEnabled": "AI Scoring Assistant", + "Permission:AI.ConfigureAI": "AI Configuration", + "Permission:AI.Prompts": "AI Prompt Management", + "Permission:AI.Prompts.Create": "Create Prompts", + "Permission:AI.Prompts.Update": "Edit Prompts", + "Permission:AI.Prompts.Delete": "Delete Prompts", + "AIPrompts": "AI Prompts", + "AIPrompt": "AI Prompt", + "AIPromptVersion": "Prompt Version", + "AIPromptVersions": "Prompt Versions", + "PromptType": "Type", + "PromptName": "Name", + "PromptDescription": "Description", + "PromptIsActive": "Active", + "VersionNumber": "Version Number", + "SystemPrompt": "System Prompt", + "UserPromptTemplate": "User Prompt Template", + "DeveloperNotes": "Developer Notes", + "TargetModel": "Target Model", + "TargetProvider": "Target Provider", + "Temperature": "Temperature", + "MaxTokens": "Max Tokens", + "IsPublished": "Published", + "IsDeprecated": "Deprecated" + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AIResource.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AIResource.cs new file mode 100644 index 0000000000..01c5e0b812 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AIResource.cs @@ -0,0 +1,9 @@ +using Volo.Abp.Localization; + +namespace Unity.AI.Localization; + +[LocalizationResourceName("AI")] +public class AIResource +{ + +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/PromptType.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/PromptType.cs new file mode 100644 index 0000000000..9b57dc1e75 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/PromptType.cs @@ -0,0 +1,10 @@ +namespace Unity.AI; + +/// Categorises what role an AI prompt plays in the system. +public enum PromptType +{ + Orchestrator = 0, + Skill = 1, + Instruction = 2, + Agent = 3 +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Settings/AISettings.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Settings/AISettings.cs new file mode 100644 index 0000000000..d335050785 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Settings/AISettings.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Settings; + +public static class AISettings +{ + public const string ScoringAssistantEnabled = "GrantManager.AI.ScoringAssistantEnabled"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj new file mode 100644 index 0000000000..4cf3ed4e73 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj @@ -0,0 +1,34 @@ + + + + + + net9.0 + enable + Unity.AI + true + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebAutoMapperProfile.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebAutoMapperProfile.cs new file mode 100644 index 0000000000..91e120f831 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebAutoMapperProfile.cs @@ -0,0 +1,11 @@ +using AutoMapper; + +namespace Unity.AI.Web; + +public class AIWebAutoMapperProfile : Profile +{ + public AIWebAutoMapperProfile() + { + // Define AutoMapper mappings for web layer here + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs new file mode 100644 index 0000000000..26b64ccb61 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.DependencyInjection; +using Unity.AI.Localization; +using Unity.AI.Web.Menus; +using Unity.AI.Web.Views.Settings; +using Volo.Abp.AspNetCore.Mvc.Localization; +using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared; +using Volo.Abp.AutoMapper; +using Volo.Abp.Modularity; +using Volo.Abp.SettingManagement.Web; +using Volo.Abp.SettingManagement.Web.Pages.SettingManagement; +using Volo.Abp.UI.Navigation; +using Volo.Abp.VirtualFileSystem; + +namespace Unity.AI.Web; + +[DependsOn( + typeof(AIApplicationModule), + typeof(AbpAspNetCoreMvcUiThemeSharedModule), + typeof(AbpAutoMapperModule), + typeof(AbpSettingManagementWebModule) + )] +public class AIWebModule : AbpModule +{ + public override void PreConfigureServices(ServiceConfigurationContext context) + { + context.Services.PreConfigure(options => + { + options.AddAssemblyResource(typeof(AIResource), typeof(AIWebModule).Assembly); + }); + + PreConfigure(mvcBuilder => + { + mvcBuilder.AddApplicationPartIfNotExists(typeof(AIWebModule).Assembly); + }); + } + + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.FileSets.AddEmbedded(); + }); + + Configure(options => + { + options.MenuContributors.Add(new AIMenuContributor()); + }); + + context.Services.AddAutoMapperObjectMapper(); + Configure(options => + { + options.AddMaps(validate: true); + }); + + Configure(options => + { + options.Contributors.Add(new AISettingPageContributor()); + }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xml new file mode 100644 index 0000000000..7e9f94ead6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd new file mode 100644 index 0000000000..3f3946e282 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed. + + + + + A comma-separated list of error codes that can be safely ignored in assembly verification. + + + + + 'false' to turn off automatic generation of the XML Schema file. + + + + + \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs new file mode 100644 index 0000000000..453ad3549a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs @@ -0,0 +1,30 @@ +using System.Threading.Tasks; +using Unity.Modules.Shared.Permissions; +using Volo.Abp.UI.Navigation; + +namespace Unity.AI.Web.Menus; + +public class AIMenuContributor : IMenuContributor +{ + public async Task ConfigureMenuAsync(MenuConfigurationContext context) + { + if (context.Menu.Name == StandardMenus.Main) + { + await ConfigureMainMenuAsync(context); + } + } + + private static Task ConfigureMainMenuAsync(MenuConfigurationContext context) + { + context.Menu.AddItem(new ApplicationMenuItem( + name: AIMenus.Prompts, + displayName: "AI Prompts", + url: "~/Prompts", + icon: "fl fl-ai-prompts", + order: 900, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + )); + + return Task.CompletedTask; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs new file mode 100644 index 0000000000..f93517efe0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs @@ -0,0 +1,8 @@ +namespace Unity.AI.Web.Menus; + +public static class AIMenus +{ + private const string Prefix = "AI"; + + public const string Prompts = Prefix + ".Prompts"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/CreateModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/CreateModal.cshtml new file mode 100644 index 0000000000..ddbdeb5bae --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/CreateModal.cshtml @@ -0,0 +1,23 @@ +@page +@using Unity.AI.Localization +@using Unity.AI.Web.Pages.Prompts +@using Microsoft.Extensions.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@model CreateModalModel +@inject IStringLocalizer L +@{ + Layout = null; +} + + + + + + + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/CreateModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/CreateModal.cshtml.cs new file mode 100644 index 0000000000..25a3b31c8b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/CreateModal.cshtml.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts; + +public class CreateModalModel : AbpPageModel +{ + [BindProperty] + public CreateUpdateAIPromptDto Prompt { get; set; } = new(); + + private readonly IAIPromptAppService _promptAppService; + + public CreateModalModel(IAIPromptAppService promptAppService) + { + _promptAppService = promptAppService; + } + + public void OnGet() + { + Prompt = new CreateUpdateAIPromptDto { IsActive = true }; + } + + public async Task OnPostAsync() + { + await _promptAppService.CreateAsync(Prompt); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml new file mode 100644 index 0000000000..5ad9cff4a4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml @@ -0,0 +1,37 @@ +@page +@using Unity.AI.Localization +@using Unity.AI.Web.Pages.Prompts +@using Microsoft.Extensions.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@model EditModalModel +@inject IStringLocalizer L +@{ + Layout = null; +} + + + + + + + + + + + + + + + +
+ +
+
+
@L["AIPromptVersions"]
+ +
+ +
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs new file mode 100644 index 0000000000..1279b7a691 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts; + +public class EditModalModel : AbpPageModel +{ + [HiddenInput] + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + [BindProperty] + public CreateUpdateAIPromptDto Prompt { get; set; } = new(); + + private readonly IAIPromptAppService _promptAppService; + + public EditModalModel(IAIPromptAppService promptAppService) + { + _promptAppService = promptAppService; + } + + public async Task OnGetAsync() + { + var dto = await _promptAppService.GetAsync(Id); + Prompt = new CreateUpdateAIPromptDto + { + Name = dto.Name, + Description = dto.Description, + Type = dto.Type, + IsActive = dto.IsActive + }; + } + + public async Task OnPostAsync() + { + await _promptAppService.UpdateAsync(Id, Prompt); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml new file mode 100644 index 0000000000..0099f5cf0e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml @@ -0,0 +1,137 @@ +@page +@using Unity.AI.Localization +@using Unity.AI.Web.Pages.Prompts +@using Microsoft.Extensions.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Layout +@model IndexModel +@inject IStringLocalizer L +@inject IPageLayout PageLayout +@{ + PageLayout.Content.BreadCrumb.Add(L["AIPrompts"].Value); +} +@section styles { + +} +@section scripts { + +} + +
+
+
+

@L["AIPrompts"]

+
+
+ +
+
+
+ +
+ + +
+
+
+ +
+
+
+ + +
+ + + + +
+
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml.cs new file mode 100644 index 0000000000..e24584432f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml.cs @@ -0,0 +1,8 @@ +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts; + +public class IndexModel : AbpPageModel +{ + +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.css b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.css new file mode 100644 index 0000000000..ffddbdf1e6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.css @@ -0,0 +1,122 @@ +/* ── Split-pane container ─────────────────────────────────────────────────── */ +.prompts-split-container { + display: flex; + flex-direction: row; + align-items: flex-start; + min-height: 400px; + gap: 0; + /* No fixed height — lets the DataTable footer and page footer remain visible */ +} + +/* ── Left pane ────────────────────────────────────────────────────────────── */ +.prompts-left-pane { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + transition: none; +} + +/* ── Drag divider ─────────────────────────────────────────────────────────── */ +.prompts-split-divider { + display: none; + width: 7px; + flex-shrink: 0; + cursor: col-resize; + background: #dee2e6; + border-left: 1px solid #ced4da; + border-right: 1px solid #ced4da; + position: sticky; + top: 10px; + height: calc(100vh - 190px); + z-index: 10; + transition: background 0.15s; +} + +.prompts-split-divider::after { + content: '⋮'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #868e96; + font-size: 20px; + line-height: 1; + pointer-events: none; +} + +.prompts-split-divider:hover, +.prompts-split-divider.dragging { + background: #adb5bd; +} + +.prompts-split-container.split-active .prompts-split-divider { + display: flex; + align-items: center; + justify-content: center; +} + +/* ── Right pane ───────────────────────────────────────────────────────────── */ +.prompts-right-pane { + flex-shrink: 0; + width: 52%; + min-width: 300px; + max-width: 80%; + overflow: hidden; + display: flex; + flex-direction: column; + /* Sticky so the editor stays visible as the left-pane table grows */ + position: sticky; + top: 10px; + /* Explicit height (not max-height) so h-100 on the inner card resolves correctly */ + height: calc(100vh - 190px); +} + +/* ── Version editor header ────────────────────────────────────────────────── */ +.version-editor-header { + background: #f8f9fa; + flex-shrink: 0; +} + +.version-select { + min-width: 160px; + max-width: 220px; +} + +/* ── Version editor form body ─────────────────────────────────────────────── */ +.version-editor-body { + flex: 1 1 0; /* 0 basis so flex shrinks below content natural size */ + min-height: 0; /* required for overflow-y:auto to work inside a flex column */ + overflow-y: auto; +} + +/* ── Resizable textareas ──────────────────────────────────────────────────── */ +.version-textarea { + resize: vertical; + min-height: 72px; + font-size: 0.82rem; + line-height: 1.5; +} + +.version-textarea--code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.version-textarea--json { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + min-height: 120px; +} + +/* ── Row selection ────────────────────────────────────────────────────────── */ +#AIPromptsTable tbody tr { + cursor: pointer; +} + +#AIPromptsTable tbody tr.prompt-selected td { + background-color: #cfe2ff !important; +} + +/* ── Drag ghost (prevent text selection while dragging) ───────────────────── */ +body.split-dragging { + cursor: col-resize !important; + user-select: none !important; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js new file mode 100644 index 0000000000..28062f58cf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js @@ -0,0 +1,380 @@ +$(function () { + const l = abp.localization.getResource('AI'); + + // Prompt-level modals (create / edit prompt metadata only) + let createModal = new abp.ModalManager(abp.appPath + 'Prompts/CreateModal'); + let editModal = new abp.ModalManager(abp.appPath + 'Prompts/EditModal'); + + // ── State ──────────────────────────────────────────────────────────────── + let currentVersionId = null; + let isNewVersion = false; + let cachedVersions = []; // versions for the currently-selected prompt + + // ── Table columns ──────────────────────────────────────────────────────── + const listColumns = [ + { + title: l('PromptName'), + name: 'name', + data: 'name', + index: 0 + }, + { + title: l('PromptType'), + name: 'type', + data: 'type', + index: 1, + render: (data) => { + const types = ['Orchestrator', 'Skill', 'Instruction', 'Agent']; + return types[data] ?? data; + } + }, + { + title: l('PromptDescription'), + name: 'description', + data: 'description', + index: 2, + defaultContent: '' + }, + { + title: l('PromptIsActive'), + name: 'isActive', + data: 'isActive', + index: 3, + render: (data) => data + ? 'Active' + : 'Inactive' + }, + { + title: l('Actions'), + data: 'id', + orderable: false, + className: 'text-center', + name: 'rowActions', + index: 4, + rowAction: { + items: [ + { + text: 'Edit Prompt', + action: (data) => editModal.open({ id: data.record.id }) + } + ] + } + } + ]; + + const responseCallback = (result) => ({ + recordsTotal: result.totalCount, + recordsFiltered: result.items.length, + data: result.items + }); + + const actionButtons = [ + { + text: ' New Prompt', + titleAttr: 'New Prompt', + id: 'CreatePromptButton', + className: 'btn btn-light rounded-1', + action: (e) => { e.preventDefault(); createModal.open(); } + } + ]; + + const defaultVisibleColumns = ['name', 'type', 'description', 'isActive', 'rowActions']; + const dt = $('#AIPromptsTable'); + + const dataTable = initializeDataTable({ + dt, + defaultVisibleColumns, + listColumns, + maxRowsPerPage: 25, + defaultSortColumn: 0, + dataEndpoint: unity.aI.prompts.aIPrompt.getList, + data: {}, + responseCallback, + actionButtons, + pagingEnabled: true, + reorderEnabled: false, + languageSetValues: {}, + dataTableName: 'AIPromptsTable', + dynamicButtonContainerId: 'dynamicButtonContainerId', + useNullPlaceholder: true, + externalSearchId: 'search-prompts', + fixedHeaders: true + }); + + createModal.onResult(() => dataTable.ajax.reload()); + editModal.onResult(() => dataTable.ajax.reload()); + + // ── Row click → open version panel ────────────────────────────────────── + $('#AIPromptsTable').on('click', 'tbody tr', function (e) { + // Don't intercept action-column clicks + if ($(e.target).closest('.dropdown, .dropdown-menu, button, a').length) return; + + const rowData = dataTable.row(this).data(); + if (!rowData) return; + + // Highlight selected row + $('#AIPromptsTable tbody tr').removeClass('prompt-selected'); + $(this).addClass('prompt-selected'); + + openVersionPanel(rowData); + }); + + // ── Open / populate right panel ────────────────────────────────────────── + function openVersionPanel(promptData) { + $('#versionEditorTitle').text(promptData.name); + $('#versionPromptId').val(promptData.id); + + // Activate split layout + $('#promptsSplitContainer').addClass('split-active'); + $('#promptsRightPane').show(); + + loadVersions(promptData.id); + } + + function loadVersions(promptId) { + unity.aI.prompts.aIPromptVersion.getByPrompt(promptId).then(function (result) { + cachedVersions = result.items || []; + const $select = $('#versionSelect'); + $select.empty(); + + if (cachedVersions.length === 0) { + $select.append(''); + clearVersionForm(0); + return; + } + + // Sort ascending by versionNumber in place so [last] is always the max + cachedVersions.sort((a, b) => a.versionNumber - b.versionNumber); + + cachedVersions.forEach(function (v) { + $select.append(``); + }); + + // Select latest (highest versionNumber) by default + const latest = cachedVersions[cachedVersions.length - 1]; + $select.val(latest.id); + populateVersionForm(latest); + }); + } + + // Version dropdown change + $('#versionSelect').on('change', function () { + const id = $(this).val(); + if (!id) return; + const v = cachedVersions.find(x => x.id === id); + if (v) { + populateVersionForm(v); + } else { + // fallback: fetch from server + unity.aI.prompts.aIPromptVersion.get(id).then(populateVersionForm); + } + }); + + // ── Populate form from a version DTO ───────────────────────────────────── + function populateVersionForm(v) { + isNewVersion = false; + currentVersionId = v.id; + + $('#versionId').val(v.id); + $('#versionNumber').val(v.versionNumber); + $('#versionTargetModel').val(v.targetModel ?? ''); + $('#versionTargetProvider').val(v.targetProvider ?? ''); + $('#versionTemperature').val(v.temperature ?? 0.2); + $('#versionMaxTokens').val(v.maxTokens ?? ''); + $('#versionIsPublished').prop('checked', v.isPublished ?? false); + $('#versionIsDeprecated').prop('checked', v.isDeprecated ?? false); + $('#versionSystemPrompt').val(v.systemPrompt ?? '').removeClass('is-invalid'); + $('#versionUserPromptTemplate').val(v.userPromptTemplate ?? '').removeClass('is-invalid'); + $('#versionDeveloperNotes').val(v.developerNotes ?? ''); + + // Pretty-print MetadataJson if valid + let meta = v.metadataJson ?? ''; + if (meta) { + try { meta = JSON.stringify(JSON.parse(meta), null, 2); } catch (e) { console.warn('MetadataJson is not valid JSON; displaying raw value.', e); } + } + $('#versionMetadataJson').val(meta); + + clearJsonError(); + $('#saveVersionBtnLabel').text('Save Version'); + } + + // ── Clear form for a brand-new version ─────────────────────────────────── + function clearVersionForm(nextVersionNumber) { + isNewVersion = true; + currentVersionId = null; + + $('#versionId').val(''); + $('#versionTargetModel').val(''); + $('#versionTargetProvider').val(''); + $('#versionTemperature').val(0.2); + $('#versionMaxTokens').val(''); + $('#versionIsPublished').prop('checked', false); + $('#versionIsDeprecated').prop('checked', false); + $('#versionSystemPrompt').val(''); + $('#versionUserPromptTemplate').val(''); + $('#versionDeveloperNotes').val(''); + $('#versionMetadataJson').val(''); + + clearJsonError(); + $('#saveVersionBtnLabel').text('Create Version'); + } + + // ── New version button ──────────────────────────────────────────────────── + $('#newVersionBtn').on('click', function () { + const maxNum = cachedVersions.reduce((max, v) => Math.max(max, v.versionNumber), -1); + const next = maxNum + 1; + + // Add a placeholder option + $('#versionSelect option[data-new]').remove(); + $('#versionSelect').prepend( + `` + ); + + clearVersionForm(next); + }); + + // ── Save / create version ───────────────────────────────────────────────── + $('#saveVersionBtn').on('click', function () { + const promptId = $('#versionPromptId').val(); + if (!promptId) return; + + // Required-field validation + const systemPrompt = $('#versionSystemPrompt').val().trim(); + const userPromptTemplate = $('#versionUserPromptTemplate').val().trim(); + let valid = true; + if (systemPrompt) { + $('#versionSystemPrompt').removeClass('is-invalid'); + } else { + $('#versionSystemPrompt').addClass('is-invalid'); + valid = false; + } + if (userPromptTemplate) { + $('#versionUserPromptTemplate').removeClass('is-invalid'); + } else { + $('#versionUserPromptTemplate').addClass('is-invalid'); + valid = false; + } + if (!valid) { + $('#versionSystemPrompt.is-invalid, #versionUserPromptTemplate.is-invalid')[0]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + return; + } + + const metaRaw = $('#versionMetadataJson').val().trim(); + if (metaRaw && !validateJson(metaRaw)) return; + + const dto = { + promptId: promptId, + versionNumber: Number.parseInt($('#versionNumber').val()) || 0, + systemPrompt: systemPrompt, + userPromptTemplate: userPromptTemplate, + developerNotes: $('#versionDeveloperNotes').val() || null, + targetModel: $('#versionTargetModel').val() || null, + targetProvider: $('#versionTargetProvider').val() || null, + temperature: Number.parseFloat($('#versionTemperature').val()) || 0.2, + maxTokens: $('#versionMaxTokens').val() ? Number.parseInt($('#versionMaxTokens').val()) : null, + isPublished: $('#versionIsPublished').is(':checked'), + isDeprecated: $('#versionIsDeprecated').is(':checked'), + metadataJson: metaRaw || null + }; + + if (isNewVersion) { + const newOpt = $('#versionSelect option[data-new]'); + dto.versionNumber = newOpt.length ? Number.parseInt(newOpt.data('num')) : 0; + + unity.aI.prompts.aIPromptVersion.create(dto) + .then(function () { + abp.notify.success('Version created'); + loadVersions(promptId); + }) + .catch(function (err) { + abp.notify.error(err?.message || 'Failed to create version'); + }); + } else { + unity.aI.prompts.aIPromptVersion.update(currentVersionId, dto) + .then(function () { + abp.notify.success('Version saved'); + loadVersions(promptId); + }) + .catch(function (err) { + abp.notify.error(err?.message || 'Failed to save version'); + }); + } + }); + + // ── Format JSON button ──────────────────────────────────────────────────── + $('#formatJsonBtn').on('click', function () { + const raw = $('#versionMetadataJson').val().trim(); + if (raw && validateJson(raw)) { + $('#versionMetadataJson').val(JSON.stringify(JSON.parse(raw), null, 2)); + } + }); + + // ── JSON validation helper ──────────────────────────────────────────────── + function validateJson(str) { + try { + JSON.parse(str); + clearJsonError(); + return true; + } catch (e) { + $('#jsonValidationMsg').text('Invalid JSON: ' + e.message).show(); + $('#versionMetadataJson').addClass('is-invalid'); + return false; + } + } + + function clearJsonError() { + $('#jsonValidationMsg').hide().text(''); + $('#versionMetadataJson').removeClass('is-invalid'); + } + + $('#versionMetadataJson').on('input', function () { + const raw = $(this).val().trim(); + if (raw) validateJson(raw); else clearJsonError(); + }); + + // ── Draggable divider ───────────────────────────────────────────────────── + const $divider = $('#promptsDivider'); + const $leftPane = $('#promptsLeftPane'); + const $rightPane = $('#promptsRightPane'); + const $container = $('#promptsSplitContainer'); + + let isDragging = false; + let dragStartX = 0; + let dragStartLeft = 0; + + $divider.on('mousedown', function (e) { + isDragging = true; + dragStartX = e.clientX; + dragStartLeft = $leftPane.width(); + $divider.addClass('dragging'); + $('body').addClass('split-dragging'); + e.preventDefault(); + }); + + $(document).on('mousemove.splitDrag', function (e) { + if (!isDragging) return; + + const totalWidth = $container.width(); + const dividerW = $divider.outerWidth(); + const delta = e.clientX - dragStartX; + let newLeft = dragStartLeft + delta; + const minLeft = totalWidth * 0.2; + const maxLeft = totalWidth * 0.8 - dividerW; + + newLeft = Math.max(minLeft, Math.min(maxLeft, newLeft)); + + const leftPct = (newLeft / totalWidth * 100).toFixed(2); + const rightPct = ((totalWidth - newLeft - dividerW) / totalWidth * 100).toFixed(2); + + $leftPane.css('flex', `0 0 ${leftPct}%`); + $rightPane.css({ 'flex': 'none', 'width': rightPct + '%' }); + }); + + $(document).on('mouseup.splitDrag', function () { + if (isDragging) { + isDragging = false; + $divider.removeClass('dragging'); + $('body').removeClass('split-dragging'); + } + }); +}); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml new file mode 100644 index 0000000000..38acee10e0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml @@ -0,0 +1,23 @@ +@page +@using Unity.AI.Localization +@using Unity.AI.Web.Pages.Prompts.Versions +@using Microsoft.Extensions.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@model CreateVersionModalModel +@inject IStringLocalizer L +@{ + Layout = null; +} + + + + + + + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs new file mode 100644 index 0000000000..ba49f50d0a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts.Versions; + +public class CreateVersionModalModel : AbpPageModel +{ + [BindProperty] + public CreateUpdateAIPromptVersionDto Version { get; set; } = new(); + + private readonly IAIPromptVersionAppService _versionAppService; + + public CreateVersionModalModel(IAIPromptVersionAppService versionAppService) + { + _versionAppService = versionAppService; + } + + public void OnGet(Guid promptId) + { + Version = new CreateUpdateAIPromptVersionDto + { + PromptId = promptId, + Temperature = 0.2 + }; + } + + public async Task OnPostAsync() + { + await _versionAppService.CreateAsync(Version); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml new file mode 100644 index 0000000000..2f1e96ea4f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml @@ -0,0 +1,24 @@ +@page +@using Unity.AI.Localization +@using Unity.AI.Web.Pages.Prompts.Versions +@using Microsoft.Extensions.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@model EditVersionModalModel +@inject IStringLocalizer L +@{ + Layout = null; +} + + + + + + + + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs new file mode 100644 index 0000000000..40390dd976 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts.Versions; + +public class EditVersionModalModel : AbpPageModel +{ + [HiddenInput] + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + [BindProperty] + public CreateUpdateAIPromptVersionDto Version { get; set; } = new(); + + private readonly IAIPromptVersionAppService _versionAppService; + + public EditVersionModalModel(IAIPromptVersionAppService versionAppService) + { + _versionAppService = versionAppService; + } + + public async Task OnGetAsync() + { + var dto = await _versionAppService.GetAsync(Id); + Version = new CreateUpdateAIPromptVersionDto + { + PromptId = dto.PromptId, + VersionNumber = dto.VersionNumber, + SystemPrompt = dto.SystemPrompt, + UserPromptTemplate = dto.UserPromptTemplate, + DeveloperNotes = dto.DeveloperNotes, + TargetModel = dto.TargetModel, + TargetProvider = dto.TargetProvider, + Temperature = dto.Temperature, + MaxTokens = dto.MaxTokens, + IsPublished = dto.IsPublished, + IsDeprecated = dto.IsDeprecated, + MetadataJson = dto.MetadataJson + }; + } + + public async Task OnPostAsync() + { + await _versionAppService.UpdateAsync(Id, Version); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/_ViewImports.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..7aa11381e3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/_ViewImports.cshtml @@ -0,0 +1,6 @@ +@using Unity.AI.Web +@using Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Pages.Shared.Components +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Properties/launchSettings.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Properties/launchSettings.json new file mode 100644 index 0000000000..29ff06b0cc --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Unity.AI.Web": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:57817;http://localhost:57818" + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj new file mode 100644 index 0000000000..40c586592c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj @@ -0,0 +1,47 @@ + + + + + + net9.0 + enable + true + Library + Unity.AI.Web + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewComponent.cs new file mode 100644 index 0000000000..3f880adaa6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewComponent.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using System.Threading.Tasks; +using Unity.AI.Settings; +using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; +using Volo.Abp.AspNetCore.Mvc.UI.Widgets; +using Volo.Abp.Settings; + +namespace Unity.AI.Web.Views.Settings.AISettingGroup; + +[Widget( + ScriptTypes = [typeof(AISettingScriptBundleContributor)], + AutoInitialize = true +)] +public class AISettingViewComponent(ISettingProvider settingProvider) : AbpViewComponent +{ + public virtual async Task InvokeAsync() + { + var model = new AISettingViewModel + { + ScoringAssistantEnabled = await settingProvider.GetAsync( + AISettings.ScoringAssistantEnabled, defaultValue: false) + }; + + return View("~/Views/Settings/AISettingGroup/Default.cshtml", model); + } + + public class AISettingScriptBundleContributor : BundleContributor + { + public override void ConfigureBundle(BundleConfigurationContext context) + { + context.Files.Add("/Views/Settings/AISettingGroup/Default.js"); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewModel.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewModel.cs new file mode 100644 index 0000000000..3ae4713935 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/AISettingViewModel.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Web.Views.Settings.AISettingGroup; + +public class AISettingViewModel +{ + public bool ScoringAssistantEnabled { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.cshtml new file mode 100644 index 0000000000..809bd07b6d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.cshtml @@ -0,0 +1,48 @@ +@model Unity.AI.Web.Views.Settings.AISettingGroup.AISettingViewModel + + + +
+
+

AI Configuration

+
+ +
+
+
+ + +
+ + +
+
+
+ + +
+
+ + + +
+
+
+
+
+
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js new file mode 100644 index 0000000000..8ed936e630 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingGroup/Default.js @@ -0,0 +1,41 @@ +$(function () { + const uiElements = { + settingForm: $('#AISettingsForm'), + saveButton: $('#AISettingsSaveButton'), + discardButton: $('#AISettingsDiscardButton') + }; + + let initialFormState = uiElements.settingForm.serialize(); + + function checkFormChanges() { + let isFormChanged = uiElements.settingForm.serialize() !== initialFormState; + uiElements.saveButton.prop('disabled', !isFormChanged); + uiElements.discardButton.prop('disabled', !isFormChanged); + } + + uiElements.settingForm.on('change', function () { + checkFormChanges(); + }); + + uiElements.settingForm.on('submit', function (event) { + event.preventDefault(); + + const scoringEnabled = $('#ScoringAssistantEnabled').is(':checked'); + + unity.aI.settings.aIConfiguration.updateScoringSettings({ + scoringAssistantEnabled: scoringEnabled + }).then(function () { + $(document).trigger('AbpSettingSaved'); + initialFormState = uiElements.settingForm.serialize(); + checkFormChanges(); + }); + }); + + uiElements.discardButton.on('click', function () { + uiElements.settingForm[0].reset(); + initialFormState = uiElements.settingForm.serialize(); + checkFormChanges(); + }); + + checkFormChanges(); +}); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingPageContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingPageContributor.cs new file mode 100644 index 0000000000..2d89a6709a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Settings/AISettingPageContributor.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Unity.AI.Permissions; +using Unity.AI.Web.Views.Settings.AISettingGroup; +using Volo.Abp.SettingManagement; +using Volo.Abp.SettingManagement.Web.Pages.SettingManagement; + +namespace Unity.AI.Web.Views.Settings; + +public class AISettingPageContributor : SettingPageContributorBase +{ + public override Task ConfigureAsync(SettingPageCreationContext context) + { + RequiredFeatures(SettingManagementFeatures.Enable); + RequiredPermissions(AIPermissions.Configuration.ConfigureAI); + + context.Groups.Add( + new SettingPageGroup( + "AI.Configuration", + "AI Configuration", + typeof(AISettingViewComponent), + order: 5 + ) + ); + + return Task.CompletedTask; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIPromptsWidget/AIPromptsWidgetViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIPromptsWidget/AIPromptsWidgetViewComponent.cs new file mode 100644 index 0000000000..b397b8ea9a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIPromptsWidget/AIPromptsWidgetViewComponent.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; +using Volo.Abp.AspNetCore.Mvc; + +namespace Unity.AI.Web.Views.Shared.Components.AIPromptsWidget; + +[ViewComponent(Name = "AIPromptsWidget")] +public class AIPromptsWidgetViewComponent : AbpViewComponent +{ + public IViewComponentResult Invoke() + { + return View(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIPromptsWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIPromptsWidget/Default.cshtml new file mode 100644 index 0000000000..cdad35bf5e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/Shared/Components/AIPromptsWidget/Default.cshtml @@ -0,0 +1,18 @@ +@using Unity.AI.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L + +
+
+
+ +
+
+
+ + + + + +
+
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/_ViewImports.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..231948b339 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Views/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln b/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln index 1b5a13db2a..201f4dfca4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln +++ b/applications/Unity.GrantManager/modules/Unity.Flex/Unity.Flex.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.Web", "src\Unity EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.HttpApi.Client.ConsoleTestApp", "test\Unity.Flex.HttpApi.Client.ConsoleTestApp\Unity.Flex.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Flex.Installer", "src\Unity.Flex.Installer\Unity.Flex.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.Installer", "src\Unity.Flex.Installer\Unity.Flex.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Permissions/FlexPermissions.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Permissions/FlexPermissions.cs similarity index 54% rename from applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Permissions/FlexPermissions.cs rename to applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Permissions/FlexPermissions.cs index 606d213668..efb26fabfe 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Permissions/FlexPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Permissions/FlexPermissions.cs @@ -1,4 +1,4 @@ -using Volo.Abp.Reflection; +using Volo.Abp.Reflection; namespace Unity.Flex.Permissions; @@ -6,6 +6,12 @@ public static class FlexPermissions { public const string GroupName = "Flex"; + public static class Worksheets + { + public const string Default = GroupName + ".Worksheets"; + public const string Delete = GroupName + ".Worksheets.Delete"; + } + public static string[] GetAll() { return ReflectionHelper.GetPublicConstantsRecursively(typeof(FlexPermissions)); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/IWorksheetAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/IWorksheetAppService.cs index e36f44c45a..13f1168010 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/IWorksheetAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/IWorksheetAppService.cs @@ -17,6 +17,7 @@ public interface IWorksheetAppService : IApplicationService Task CloneAsync(Guid id); Task PublishAsync(Guid id); Task DeleteAsync(Guid id); + Task GetLinkedFormsAsync(Guid worksheetId); Task ResequenceSectionsAsync(Guid id, uint oldIndex, uint newIndex); Task ExistsAsync(Guid worksheetId); Task ExportWorksheet(Guid worksheetId); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/WorksheetLinkedFormsDto.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/WorksheetLinkedFormsDto.cs new file mode 100644 index 0000000000..59be5e4b47 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/Worksheets/WorksheetLinkedFormsDto.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Unity.Flex.Worksheets +{ + public class WorksheetLinkedFormsDto + { + public List FormVersionIdsWithInstances { get; set; } = []; + public List LinkedFormVersionIds { get; set; } = []; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs index b60d3f98c7..4201df50ed 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs @@ -11,5 +11,6 @@ public interface IWorksheetInstanceRepository : IBasicRepository> GetByWorksheetCorrelationAsync(Guid worksheetId, string uiAnchor, Guid worksheetCorrelationId, string worksheetCorrelationProvider); Task GetWithValuesAsync(Guid worksheetInstanceId); Task ExistsAsync(Guid worksheetId, Guid instanceCorrelationId, string instanceCorrelationProvider, Guid sheetCorrelationId, string sheetCorrelationProvider, string? uiAnchor); + Task AnyByWorksheetAndFormVersionAsync(Guid worksheetId, Guid formVersionId); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs index ee92456a11..71edcca8b6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Unity.Flex.Domain.WorksheetInstances; +using Unity.Modules.Shared.Correlation; using Volo.Abp.Domain.Repositories.EntityFrameworkCore; using Volo.Abp.EntityFrameworkCore; @@ -50,11 +51,11 @@ public async Task> GetByWorksheetCorrelationAsync(Guid w .FirstOrDefaultAsync(wi => wi.Id == worksheetInstanceId); } - public async Task ExistsAsync(Guid worksheetId, - Guid instanceCorrelationId, - string instanceCorrelationProvider, - Guid sheetCorrelationId, - string sheetCorrelationProvider, + public async Task ExistsAsync(Guid worksheetId, + Guid instanceCorrelationId, + string instanceCorrelationProvider, + Guid sheetCorrelationId, + string sheetCorrelationProvider, string? uiAnchor) { var dbSet = await GetDbSetAsync(); @@ -67,5 +68,13 @@ public async Task ExistsAsync(Guid worksheetId, && s.WorksheetCorrelationProvider == sheetCorrelationProvider && s.UiAnchor == uiAnchor); } + + public async Task AnyByWorksheetAndFormVersionAsync(Guid worksheetId, Guid formVersionId) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.AnyAsync(s => s.WorksheetId == worksheetId + && s.WorksheetCorrelationId == formVersionId + && s.WorksheetCorrelationProvider == CorrelationConsts.FormVersion); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Permissions/FlexPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Permissions/FlexPermissionDefinitionProvider.cs index 07d9f99706..f60eeac426 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Permissions/FlexPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Permissions/FlexPermissionDefinitionProvider.cs @@ -9,6 +9,19 @@ public class FlexPermissionDefinitionProvider : PermissionDefinitionProvider public override void Define(IPermissionDefinitionContext context) { context.AddGroup(FlexPermissions.GroupName, L("Permission:Flex")); + + var settingsMgmt = context.GetGroupOrNull("SettingManagement"); + if (settingsMgmt != null) + { + var configureWorksheet = settingsMgmt.AddPermission( + FlexPermissions.Worksheets.Default, + L("Permission:Flex.Worksheets") + ); + configureWorksheet.AddChild( + FlexPermissions.Worksheets.Delete, + L("Permission:Flex.Worksheets.Delete") + ); + } } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs index 47114d2e2e..90b41405a2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetAppService.cs @@ -7,8 +7,12 @@ using Unity.Flex.Domain.Services; using Unity.Flex.Domain.Settings; using Unity.Flex.Domain.Utils; +using Unity.Flex.Domain.WorksheetInstances; +using Unity.Flex.Domain.WorksheetLinks; using Unity.Flex.Domain.Worksheets; +using Unity.Flex.Permissions; using Unity.Flex.Reporting.FieldGenerators; +using Unity.Modules.Shared.Correlation; using Unity.Modules.Shared.Features; using Volo.Abp; using Volo.Abp.Features; @@ -19,7 +23,9 @@ namespace Unity.Flex.Worksheets public partial class WorksheetAppService(IWorksheetRepository worksheetRepository, WorksheetsManager worksheetsManager, IReportingFieldsGeneratorService reportingFieldsGeneratorService, - IFeatureChecker featureChecker) : FlexAppService, IWorksheetAppService + IFeatureChecker featureChecker, + IWorksheetLinkRepository worksheetLinkRepository, + IWorksheetInstanceRepository worksheetInstanceRepository) : FlexAppService, IWorksheetAppService { public virtual async Task GetAsync(Guid id) { @@ -118,11 +124,41 @@ public virtual async Task PublishAsync(Guid id) return await Task.FromResult(true); } + [Authorize(FlexPermissions.Worksheets.Delete)] public virtual async Task DeleteAsync(Guid id) { + var linkedForms = await GetLinkedFormsAsync(id); + + if (linkedForms.FormVersionIdsWithInstances.Count > 0) + { + throw new UserFriendlyException("This worksheet cannot be deleted because it has existing instances."); + } + + if (linkedForms.LinkedFormVersionIds.Count > 0) + { + throw new UserFriendlyException("This worksheet cannot be deleted because it is still linked to one or more forms. Unlink it first."); + } + await worksheetRepository.DeleteAsync(id); } + public virtual async Task GetLinkedFormsAsync(Guid worksheetId) + { + var links = await worksheetLinkRepository.GetListByWorksheetAsync(worksheetId, CorrelationConsts.FormVersion); + var result = new WorksheetLinkedFormsDto(); + + foreach (var correlationId in links.Select(link => link.CorrelationId)) + { + bool hasInstances = await worksheetInstanceRepository.AnyByWorksheetAndFormVersionAsync(worksheetId, correlationId); + if (hasInstances) + result.FormVersionIdsWithInstances.Add(correlationId); + else + result.LinkedFormVersionIds.Add(correlationId); + } + + return result; + } + public virtual async Task ResequenceSectionsAsync(Guid id, uint oldIndex, uint newIndex) { if (oldIndex == newIndex) return; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs index b04396a5f7..8b7ae33fc5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs @@ -34,6 +34,31 @@ var ct when IsCheckBoxColumn(ct) => ValueConverterHelpers.ConvertCheckbox(value) }; } + public static string ApplyInputFormatting(this string value, string columnType, PresentationSettings presentationSettings) + { + if (value == null) return string.Empty; + + return columnType switch + { + var ct when IsDateColumn(ct) && TryParseDate(value, out string formatted) => formatted, + var ct when IsDateTimeColumn(ct) && TryParseDateTimeForInput(value, presentationSettings.BrowserOffsetMinutes, out string formatted) => formatted, + var ct when IsCurrencyColumn(ct) => ValueConverterHelpers.ConvertDecimal(value), + _ => value + }; + } + + private static bool TryParseDateTimeForInput(string value, int browserOffsetMinutes, out string formatted) + { + if (DateTimeOffset.TryParse(value, new CultureInfo("en-CA"), DateTimeStyles.None, out DateTimeOffset dateTimeOffset)) + { + dateTimeOffset = dateTimeOffset.ToOffset(TimeSpan.FromMinutes(-browserOffsetMinutes)); + formatted = dateTimeOffset.DateTime.ToString("yyyy-MM-ddTHH:mm", CultureInfo.InvariantCulture); + return true; + } + formatted = string.Empty; + return false; + } + private static bool TryParseDateTime(string value, int browserOffsetMinutes, out string formattedDateTime) { // Apply the browser offset before presenting the data diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json index 853d66df36..c615f56148 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json @@ -33,6 +33,9 @@ "Worksheet:Configuration:AddSelectListOptionText": "Add Option", "Worksheet:Configuration:AddColumnOptionText": "Add Column", "DataGrids:DynamicColumnsHeader": "Dynamic Columns", - "DataGrids:CustomColumnsHeader": "Custom Columns" + "DataGrids:CustomColumnsHeader": "Custom Columns", + "DataGrids:PredefinedColumn": "Predefined column", + "Permission:Flex.Worksheets": "Configure Worksheet", + "Permission:Flex.Worksheets.Delete": "Delete Worksheet" } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs index a08a9e2452..c215e80c1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Definitions; using Unity.Flex.Worksheets.Values; using Volo.Abp; using Volo.Abp.Application.Services; @@ -42,7 +44,7 @@ internal static Dictionary ApplyPresentationFormat( return formattedKeyValuePairs; } - internal async Task<(KeyValuePair[] dynamicFields, List? customFields)> GetPropertiesAsync(RowInputData dataProps, PresentationSettings presentationSettings) + internal async Task<(DynamicFieldMap[] dynamicFields, List? customFields)> GetPropertiesAsync(RowInputData dataProps, PresentationSettings presentationSettings) { if (IsFirstRow(dataProps)) { @@ -79,7 +81,7 @@ private async Task> GetNewRowAsync(RowInputData da return DataGridServiceUtils.ExtractCustomColumnsValues(dataGridValue, datagridDefinition, dataProps.Row, true); } - private async Task<(KeyValuePair[] dynamicFields, List customFields)> GetExistingRowAsync(RowInputData dataProps, PresentationSettings presentationSettings) + private async Task<(DynamicFieldMap[] dynamicFields, List customFields)> GetExistingRowAsync(RowInputData dataProps, PresentationSettings presentationSettings) { if (dataProps.ValueId == null) throw new ArgumentNullException(nameof(dataProps)); var customFieldValue = await customFieldValueAppService.GetAsync(dataProps.ValueId.Value); @@ -90,7 +92,11 @@ private async Task> GetNewRowAsync(RowInputData da var dataGridValue = JsonSerializer.Deserialize(customFieldValue.CurrentValue ?? "{}"); - return (DataGridServiceUtils.ExtractDynamicColumnsPairs(dataGridValue, dataProps.Row, presentationSettings), + var definition = JsonSerializer.Deserialize(datagridDefinition.Definition ?? "{}"); + var customColumnKeys = new HashSet( + definition?.Columns.Select(c => c.Name) ?? [], StringComparer.Ordinal); + + return (DataGridServiceUtils.ExtractDynamicColumnsValues(dataGridValue, dataProps.Row, presentationSettings, customColumnKeys), DataGridServiceUtils.ExtractCustomColumnsValues(dataGridValue, datagridDefinition, dataProps.Row, false)); } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs index 8c8acbd6e1..402db923fc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; using Unity.Flex.Worksheets; @@ -32,10 +33,12 @@ internal static List ExtractCustomColumnsValues(DataGri foreach (var column in definition.Columns) { + var displayName = value?.Columns?.Find(c => c.Key == column.Name)?.Name ?? column.Name; + fieldsToEdit.Add(new WorksheetFieldViewModel() { Name = column.Name, - Label = column.Name, + Label = displayName, Id = Guid.Empty, CurrentValue = GetCurrentValueAndTransform(dataRow, column), CurrentValueId = Guid.Empty, @@ -59,7 +62,18 @@ internal static string GetDefaultDefinition(string type) { if (dataRow == null) return null; var cell = dataRow.Cells.Find(s => s.Key == column.Name); - return ValueConverter.Convert(cell?.Value ?? string.Empty, CustomFieldType.Text); + var columnType = Enum.Parse(column.Type); + var rawValue = cell?.Value ?? string.Empty; + + // Normalize DateTime values to yyyy-MM-ddTHH:mm for HTML datetime-local input compatibility + if (columnType == CustomFieldType.DateTime + && !string.IsNullOrEmpty(rawValue) + && DateTime.TryParse(rawValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) + { + rawValue = dt.ToString("yyyy-MM-ddTHH:mm", CultureInfo.InvariantCulture); + } + + return ValueConverter.Convert(rawValue, columnType); } internal static CustomFieldType ResolveTypeColumnName(string key, DataGridDefinition? definition) @@ -105,6 +119,8 @@ private static string FormatValue(string value, CustomFieldType type) return type switch { CustomFieldType.Currency => ValueConverterHelpers.ConvertDecimal(value), + CustomFieldType.Date => ValueConverterHelpers.ConvertDate(value), + CustomFieldType.DateTime => ValueConverterHelpers.ConvertDateTime(value), CustomFieldType.YesNo => ValueConverterHelpers.ConvertYesNo(value), CustomFieldType.Checkbox => ValueConverterHelpers.ConvertCheckbox(value), _ => value @@ -125,11 +141,12 @@ internal static List SetRowCells(List[] ExtractDynamicColumnsPairs(DataGridValue? dataGridValue, + internal static DynamicFieldMap[] ExtractDynamicColumnsValues(DataGridValue? dataGridValue, uint rowNumber, - PresentationSettings presentationSettings) + PresentationSettings presentationSettings, + HashSet? excludeKeys = null) { - var keyValues = new List>(); + var keyValues = new List(); var gridValue = DeserializeDataGridValue(dataGridValue?.Value?.ToString()); if (gridValue == null) return []; var gridRowsValue = DeserializeDataGridRowsValue(dataGridValue?.Value?.ToString()); @@ -138,11 +155,21 @@ internal static KeyValuePair[] ExtractDynamicColumnsPairs(DataGr foreach (var column in dataGridValue?.Columns ?? []) { + if (excludeKeys != null && excludeKeys.Contains(column.Key)) + continue; + var cell = row.Cells.Find(s => s.Key == column.Key); if (cell != null) { - keyValues.Add(new(column.Name, cell.Value.ApplyPresentationFormatting(column.Type, null, presentationSettings))); + keyValues.Add(new DynamicFieldMap + { + Key = column.Key, + Name = column.Name, + Value = cell.Value.ApplyPresentationFormatting(column.Type, null, presentationSettings), + InputValue = cell.Value.ApplyInputFormatting(column.Type, presentationSettings), + Type = column.Type + }); } } @@ -173,4 +200,13 @@ public class WriteDataRowResponse public Guid WorksheetId { get; set; } public uint Row { get; set; } } + + public class DynamicFieldMap + { + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string InputValue { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml index 61770a1f6b..cec22d74f7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml @@ -1,6 +1,4 @@ @page -@using Microsoft.Extensions.Localization; -@using Unity.Flex.Localization; @using Unity.Flex.Web.Views.Shared.Components.CheckboxWidget; @using Unity.Flex.Web.Views.Shared.Components.CurrencyWidget; @using Unity.Flex.Web.Views.Shared.Components.DateWidget; @@ -8,11 +6,14 @@ @using Unity.Flex.Web.Views.Shared.Components.YesNoWidget @using Unity.Flex.Worksheets; @using Unity.Flex; +@using Unity.Flex.Localization; +@using Microsoft.Extensions.Localization; @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal; +@inject IStringLocalizer L + @model Unity.Flex.Web.Pages.Flex.EditDataRowModalModel; -@inject IStringLocalizer L @{ Layout = null; } @@ -31,29 +32,80 @@ -
- @if (Model.DynamicFields?.Length > 0) + +
+ @foreach (var entry in Model.AllFields) { -
-
@L["DataGrids:DynamicColumnsHeader"]:
- @foreach (var field in Model.DynamicFields ?? []) - { -
- - -
- } -
- } -
- @if (Model.DynamicFields?.Length > 0) + @if (entry.IsDynamic) { -
@L["DataGrids:CustomColumnsHeader"]:
+ var df = entry.DynamicField!; + var fieldType = Enum.TryParse(df.Type, out var parsed) ? parsed : CustomFieldType.Text; + var isEditable = fieldType is CustomFieldType.Text or CustomFieldType.TextArea + or CustomFieldType.Currency or CustomFieldType.Numeric + or CustomFieldType.Date or CustomFieldType.DateTime + or CustomFieldType.Checkbox or CustomFieldType.Phone or CustomFieldType.Email; +
+ @if (!isEditable) + { + + + } + else if (fieldType == CustomFieldType.Checkbox) + { +
+ @if (df.Value.IsTruthy()) + { + + } + else + { + + } + +
+ } + else if (fieldType == CustomFieldType.Currency) + { + +
+ $ + + +
+ } + else if (fieldType == CustomFieldType.TextArea) + { + + + } + else + { + + + } +
} - @foreach (var field in Model.Properties ?? []) + else { + var field = entry.CustomField!;
- + @try { @switch (field.Type) @@ -111,7 +163,7 @@
} -
+ }
@@ -167,11 +219,11 @@ function rowEditFormHasInvalidCurrencyCustomFields(formId) { let invalidFieldsFound = false; - $("#" + formId + " input:visible").each(function (i, el) { + $('#' + formId + ' input:visible').each(function (i, el) { let $field = $(this); if ($field.hasClass('custom-currency-input')) { if ($field.val() === '' || $field.val() === '0') { - $field.val('0.00'); + $field.val('0.00'); } if (!isValidCurrencyCustomField($field)) { invalidFieldsFound = true; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs index 6aed835b6a..50fc621fa5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs @@ -2,10 +2,13 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using System.Linq; +using Unity.Flex.Worksheets; using Unity.Modules.Shared.Utils; namespace Unity.Flex.Web.Pages.Flex; @@ -45,11 +48,18 @@ public class EditDataRowModalModel(DataGridWriteService dataGridWriteService, public List? Properties { get; set; } [BindProperty] - public KeyValuePair[]? DynamicFields { get; set; } + public DynamicFieldMap[]? DynamicFields { get; set; } [BindProperty] public string? CheckboxKeys { get; set; } + [BindProperty] + public string? DynamicKeyMap { get; set; } + + public List AllFields { get; private set; } = []; + + private const string DynamicFieldPrefix = "dynamicXdF-"; + public async Task OnGetAsync(Guid valueId, Guid fieldId, uint row, @@ -86,24 +96,55 @@ public async Task OnGetAsync(Guid valueId, PresentationSettings presentationSettings = new() { BrowserOffsetMinutes = browserUtils.GetBrowserOffset() }; var (dynamicFields, customFields) = await dataGridReadService.GetPropertiesAsync(dataProps, presentationSettings); Properties = customFields; - DynamicFields = dynamicFields ?? []; - CheckboxKeys = string.Join(',', Properties?.Where(s => s.Type == Worksheets.CustomFieldType.Checkbox).Select(s => s.Name) ?? []); + DynamicFields = PrefixDynamicFields(dynamicFields ?? []); + + var customCheckboxKeys = Properties?.Where(s => s.Type == CustomFieldType.Checkbox).Select(s => s.Name) ?? []; + var dynamicCheckboxKeys = DynamicFields?.Where(df => df.Type == CustomFieldType.Checkbox.ToString()).Select(df => df.Key[DynamicFieldPrefix.Length..]) ?? []; + CheckboxKeys = string.Join(',', customCheckboxKeys.Concat(dynamicCheckboxKeys)); + + var keyMap = DynamicFields?.ToDictionary( + df => df.Key[DynamicFieldPrefix.Length..], + df => new DynamicKeyMapEntry(df.Name, df.Type) + ) ?? new Dictionary(); + + foreach (var cf in Properties ?? []) + { + keyMap.TryAdd(cf.Name, new DynamicKeyMapEntry(cf.Label, cf.Type.ToString(), IsDynamic: false)); + } + + DynamicKeyMap = JsonSerializer.Serialize(keyMap); + + AllFields = MergeAndSortFields(DynamicFields ?? [], Properties ?? []); + } + + private static DynamicFieldMap[] PrefixDynamicFields(DynamicFieldMap[] dynamicFieldMaps) + { + foreach (var map in dynamicFieldMaps) + { + map.Key = $"{DynamicFieldPrefix}{map.Key}"; + } + + return dynamicFieldMaps; } public async Task OnPostAsync() { - var keyValuePairs = GetKeyValuePairs(Request.Form); + var keyValuePairs = StripDynamicFieldPrefix(GetKeyValuePairs(Request.Form)); var presentationSettings = new PresentationSettings() { BrowserOffsetMinutes = browserUtils.GetBrowserOffset() }; if (CheckboxKeys != null) { - var keysToCheck = CheckboxKeys.Split(','); - foreach (var key in keysToCheck) + foreach (var key in CheckboxKeys.Split(',')) { - keyValuePairs.TryAdd(key, "false"); + keyValuePairs[key] = keyValuePairs.TryGetValue(key, out var existing) && existing.IsTruthy() + ? "true" + : "false"; } } + var dynamicTypeMap = JsonSerializer.Deserialize>(DynamicKeyMap ?? "{}") ?? []; + ConvertDateTimeValuesForStorage(keyValuePairs, dynamicTypeMap, presentationSettings.BrowserOffsetMinutes); + var dataProps = new RowInputData() { FieldId = FieldId, @@ -119,6 +160,8 @@ public async Task OnPostAsync() }; var result = await dataGridWriteService.WriteRowAsync(dataProps); + var updates = DataGridReadService.ApplyPresentationFormat(keyValuePairs, result.MappedValues, presentationSettings); + ApplyDynamicFieldPresentationFormat(updates, dynamicTypeMap, presentationSettings); return new OkObjectResult(new ModalResponse() { @@ -128,10 +171,10 @@ public async Task OnPostAsync() WorksheetId = result.WorksheetId, Row = result.Row, IsNew = result.IsNew, - Updates = DataGridReadService.ApplyPresentationFormat(keyValuePairs, result.MappedValues, presentationSettings), + Updates = updates, UiAnchor = UiAnchor }); - } + } private static Dictionary GetKeyValuePairs(IFormCollection form) { @@ -147,9 +190,95 @@ where Regex.IsMatch(key, pattern, RegexOptions.None, TimeSpan.FromSeconds(30)) keyValuePairs[prefix] = value.ToString(); } + foreach (string key in form.Keys.Where(key => + key.StartsWith(DynamicFieldPrefix, StringComparison.Ordinal) && !keyValuePairs.ContainsKey(key))) + { + keyValuePairs[key] = form[key].ToString(); + } + return keyValuePairs; } + private static Dictionary StripDynamicFieldPrefix(Dictionary keyValuePairs) + { + var result = new Dictionary(); + foreach (var kvp in keyValuePairs) + { + var key = kvp.Key.StartsWith(DynamicFieldPrefix, StringComparison.Ordinal) + ? kvp.Key[DynamicFieldPrefix.Length..] + : kvp.Key; + result[key] = kvp.Value; + } + return result; + } + + private static void ConvertDateTimeValuesForStorage( + Dictionary keyValuePairs, + Dictionary dynamicTypeMap, + int browserOffsetMinutes) + { + var browserOffset = TimeSpan.FromMinutes(-browserOffsetMinutes); + var dateTimeType = CustomFieldType.DateTime.ToString(); + + foreach (var (key, _) in dynamicTypeMap.Where(e => e.Value.IsDynamic && e.Value.Type == dateTimeType)) + { + if (keyValuePairs.TryGetValue(key, out var rawValue) + && !string.IsNullOrEmpty(rawValue) + && DateTime.TryParse(rawValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var localDateTime)) + { + var dto = new DateTimeOffset(localDateTime, browserOffset); + keyValuePairs[key] = dto.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture); + } + } + } + + private static void ApplyDynamicFieldPresentationFormat( + Dictionary updates, + Dictionary dynamicTypeMap, + PresentationSettings presentationSettings) + { + foreach (var (key, entry) in dynamicTypeMap.Where(e => + e.Value.IsDynamic && updates.ContainsKey(e.Key) && !string.IsNullOrEmpty(updates[e.Key]))) + { + updates[key] = updates[key].ApplyPresentationFormatting(entry.Type, null, presentationSettings); + } + } + + private sealed record DynamicKeyMapEntry(string Name, string Type, bool IsDynamic = true); + + private static List MergeAndSortFields(DynamicFieldMap[] dynamicFields, List customFields) + { + var fields = new List(); + + foreach (var df in dynamicFields) + { + fields.Add(new EditRowField + { + SortKey = df.Name, + DynamicField = df + }); + } + + foreach (var cf in customFields) + { + fields.Add(new EditRowField + { + SortKey = cf.Label, + CustomField = cf + }); + } + + return [.. fields.OrderBy(f => f.SortKey, StringComparer.OrdinalIgnoreCase)]; + } + + public class EditRowField + { + public string SortKey { get; set; } = string.Empty; + public WorksheetFieldViewModel? CustomField { get; set; } + public DynamicFieldMap? DynamicField { get; set; } + public bool IsDynamic => DynamicField != null; + } + public class ModalResponse { public uint Row { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs index 6f7b01918d..50b64b5e13 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs @@ -8,6 +8,7 @@ public class DataGridViewModel : WorksheetViewModelBase { public bool AllowEdit { get; set; } public string[] Columns { get; set; } = []; + public string[] ColumnKeys { get; set; } = []; public string TableOptions { get; set; } = string.Empty; public DataGridViewModelRow[] Rows { get; set; } = []; public DataGridDefinitionSummaryOption SummaryOption { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs index 9c90de0d29..c020c735c7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs @@ -106,12 +106,14 @@ private IViewComponentResult GenerateGridView(WorksheetFieldViewModel fieldModel var dataColumns = GenerateDataColumns(dataGridValue, dataGridDefinition); var dataRows = GenerateDataRows(dataColumns, dataGridRowsValue, presentationSetttings); var columnNames = dataColumns.Select(s => s.Name); + var columnKeys = dataColumns.Select(s => s.Key); var viewModel = new DataGridViewModel() { Field = fieldModel, Name = modelName, Columns = [.. columnNames], + ColumnKeys = [.. columnKeys], Rows = [.. dataRows], AllowEdit = true, SummaryOption = ConvertSummaryOption(dataGridDefinition), @@ -140,7 +142,9 @@ private static List GenerateDataColumns(DataGridValue? dataGridV { // Predefined column definitions List dataColumns = dataGridValue?.Columns ?? []; - foreach (var dataColumn in dataGridDefinition?.Columns ?? []) + var existingKeys = new HashSet(dataColumns.Select(c => c.Key), StringComparer.Ordinal); + + foreach (var dataColumn in (dataGridDefinition?.Columns ?? []).Where(c => !existingKeys.Contains(c.Name))) { dataColumns.Add(new DataGridColumn() { @@ -225,6 +229,7 @@ private IViewComponentResult GenerateNonDynamicWithColumnsPreview(WorksheetField return View(new DataGridViewModel() { Columns = [.. columnsToRender], + ColumnKeys = [.. columnsToRender], Summary = summary, Rows = [.. previewRows], SummaryOption = ConvertSummaryOption(dataGridDefinition), @@ -247,6 +252,7 @@ private IViewComponentResult GenerateDynamicWithColumnsPreview(WorksheetFieldVie return View(new DataGridViewModel() { Columns = [.. columnsToRender], + ColumnKeys = [.. columnsToRender], Summary = summary, Rows = [.. previewRows], SummaryOption = ConvertSummaryOption(dataGridDefinition), @@ -261,11 +267,12 @@ private IViewComponentResult GenerateDynamicWithNoColumnsPreview(WorksheetFieldV return View(new DataGridViewModel() { Columns = [.. GenerateDynamicPlaceholderColumn()], + ColumnKeys = [.. GenerateDynamicPlaceholderColumn()], Rows = [.. GenerateDynamicRowPlaceholder()], Summary = GenerateDynamicPlaceholderSummary(), SummaryOption = ConvertSummaryOption(dataGridDefinition), Field = fieldModel, - AllowEdit = false, + AllowEdit = true, TableOptions = GenerateAvailableTableOptions(false) }); } @@ -382,23 +389,19 @@ private static DataGridViewSummary GenerateSummary(DataGridColumn[]? dataColumns private static string SumCells(string? key, DataGridViewModelRow[] rows) { decimal sum = 0; - foreach (var row in rows) + foreach (var cell in rows.Select(row => row.Cells.Find(x => x.Key == key)).Where(cell => cell != null)) { - var cell = row.Cells.Find(x => x.Key == key); - if (cell != null) + var preparse = cell!.Value.Replace("$", "").Replace(",", ""); + if (decimal.TryParse(preparse, out decimal value)) { - var preparse = cell.Value.Replace("$", "").Replace(",", ""); - if (decimal.TryParse(preparse, out decimal value)) + if (decimal.MaxValue - sum >= value) { - if (decimal.MaxValue - sum >= value) - { - sum += value; - } - else - { - sum = decimal.MaxValue; - break; - } + sum += value; + } + else + { + sum = decimal.MaxValue; + break; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml index bb81408961..9fe91322c3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml @@ -28,7 +28,7 @@
- + - @foreach (var column in Model.Columns) + @for (int i = 0; i < Model.Columns.Length; i++) { - + } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css index 60cd6e7752..9d075a3c6a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css @@ -42,21 +42,14 @@ padding-bottom: 1rem; } -.custom-fields-split { +.edit-row-fields { display: flex; - flex-direction: row; - justify-content: space-around; - align-items: flex-start; -} - -.custom-fields-split > div { - width: 100%; -} - -.customgrid-edit-row-header { - margin-bottom: 1rem; + flex-direction: column; } -.customgrid-edit-dymanic-field { - width: 90%; +.static-field-indicator { + font-size: 0.7em; + color: #8a8886; + margin-left: 4px; + vertical-align: middle; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js index 69d90ae048..0fc4c6c6b7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js @@ -84,8 +84,8 @@ $(function () { if (isNewRow) { // Convert dataToUpdate object to an array of values in the same order as the columns let newRowData = table.columns().header().toArray().map(header => { - let columnName = $(header).text(); - return dataToUpdate[columnName] !== undefined ? dataToUpdate[columnName] : ''; + let key = $(header).data('key'); + return dataToUpdate[key] === undefined ? '' : dataToUpdate[key]; }); // Add a placeholder for the button in the last column @@ -152,11 +152,11 @@ $(function () { return !isNaN(value) && isFinite(value); } - // Function to get the index of a column by its name - function getColumnIndex(table, columnName) { + // Function to get the index of a column by its key + function getColumnIndex(table, key) { let headers = table.columns().header().toArray(); for (let i = 0; i < headers.length; i++) { - if ($(headers[i]).text() === columnName) { + if ($(headers[i]).data('key') === key) { return i; } } return -1; // Return -1 if the column is not found diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Default.cshtml index 9a8fc69136..7455e7ae1e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Default.cshtml @@ -1,5 +1,6 @@ @using Microsoft.AspNetCore.Mvc.Localization @using Unity.Flex.Localization; +@using Unity.Flex.Permissions; @using Unity.Flex.Web.Views.Shared.Components.Worksheets; @using Volo.Abp.Authorization.Permissions; @@ -31,6 +32,12 @@ + @if (await PermissionChecker.IsGrantedAsync(FlexPermissions.Worksheets.Delete)) + { + + } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Worksheet.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Worksheet.js index 0177fdd53a..90f290ecbf 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Worksheet.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/WorksheetWidget/Worksheet.js @@ -79,6 +79,15 @@ $(function () { }); } + let deleteWorksheetButtons = $(".delete-worksheet-btn"); + + if (deleteWorksheetButtons) { + deleteWorksheetButtons.on("click", function (event) { + let btn = event.currentTarget; + handleDeleteWorksheet(btn.dataset.worksheetId, btn.dataset.worksheetTitle, btn.dataset.worksheetName); + }); + } + setupTooltips(); } @@ -223,3 +232,46 @@ $(function () { } ); }); + +function handleDeleteWorksheet(worksheetId, worksheetTitle, worksheetName) { + unity.grantManager.settingManagement.worksheetConfiguration.getDeletionCheck(worksheetId) + .done(function (result) { + if (result.blockingFormNames && result.blockingFormNames.length > 0) { + abp.message.error( + 'This worksheet cannot be deleted because it is already used by the following forms:\n' + result.blockingFormNames.join('\n'), + 'Delete Worksheet' + ); + } else if (result.linkedFormNames && result.linkedFormNames.length > 0) { + abp.message.error( + 'Unlink the worksheet (' + worksheetTitle + ' \u2013 ' + worksheetName + ') from the following forms before deletion:\n' + result.linkedFormNames.join('\n'), + 'Delete Worksheet' + ); + } else { + abp.message.confirm( + 'Are you sure you want to delete the worksheet "' + worksheetTitle + '"?', + 'Delete Worksheet', + function (confirmed) { + if (confirmed) { + executeWorksheetDelete(worksheetId); + } + } + ); + } + }) + .fail(function (e) { + abp.notify.error('Failed to check worksheet deletion status.'); + console.warn('Worksheet deletion check failed:', e); + }); +} + +function executeWorksheetDelete(worksheetId) { + unity.flex.worksheets.worksheet.delete(worksheetId) + .done(function () { + PubSub.publish('refresh_worksheet_list', { worksheetId: worksheetId, action: 'Delete' }); + abp.notify.success('Worksheet deleted successfully.', 'Delete Worksheet'); + }) + .fail(function (e) { + abp.notify.error('Failed to delete worksheet.'); + console.warn('Worksheet deletion failed:', e); + }); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln b/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln index 9e3121eb10..b3370928f9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/Unity.Notifications.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Notifications.Web", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Notifications.HttpApi.Client.ConsoleTestApp", "test\Unity.Notifications.HttpApi.Client.ConsoleTestApp\Unity.Notifications.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Notifications.Installer", "src\Unity.Notifications.Installer\Unity.Notifications.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Notifications.Installer", "src\Unity.Notifications.Installer\Unity.Notifications.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailLogAttachmentDto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailLogAttachmentDto.cs new file mode 100644 index 0000000000..fcccb3545d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailLogAttachmentDto.cs @@ -0,0 +1,16 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Unity.Notifications.Emails; + +[Serializable] +public class EmailLogAttachmentDto : EntityDto +{ + public string? FileName { get; set; } + public string? DisplayName { get; set; } + public DateTime Time { get; set; } + public long FileSize { get; set; } + public string ContentType { get; set; } = string.Empty; + public string S3ObjectKey { get; set; } = string.Empty; + public string AttachedBy { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentAppService.cs new file mode 100644 index 0000000000..bb9377063e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentAppService.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.Notifications.Emails; + +public interface IEmailLogAttachmentAppService : IApplicationService +{ + Task> GetListByEmailLogIdAsync(Guid emailLogId); + Task DeleteAsync(Guid id); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs new file mode 100644 index 0000000000..cdea764cb9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.Notifications.Emails; + +public interface IEmailLogAttachmentUploadService +{ + Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType); + Task GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs index eb031554d0..77a905aada 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs @@ -86,8 +86,36 @@ public class EmailNotificationManager( } } + public async Task CreateDraftEmailLogAsync(Guid applicationId) + { + var emailLog = new EmailLog + { + ApplicationId = applicationId, + Status = EmailStatus.Draft + }; + return await emailLogsRepository.InsertAsync(emailLog, autoSave: true); + } + public async Task DeleteEmailLogAsync(Guid id) { + var emailLog = await emailLogsRepository.GetAsync(id); + if (emailLog.Status == EmailStatus.Sent) + { + throw new UserFriendlyException("Sent emails cannot be deleted."); + } + + var attachments = await emailAttachmentService.GetAttachmentsAsync(id); + foreach (var s3Key in attachments.Select(attachment => attachment.S3ObjectKey)) + { + try + { + await emailAttachmentService.DeleteFromS3Async(s3Key); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete S3 attachment for EmailLog {EmailLogId}", id); + } + } await emailLogsRepository.DeleteAsync(id); } 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 ffeb49a214..87881499f4 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 @@ -31,6 +31,12 @@ public class EmailNotificationService( IFeatureChecker featureChecker) : ApplicationService, IEmailNotificationService { + public async Task InitializeDraftAsync(Guid applicationId) + { + var emailLog = await emailNotificationManager.CreateDraftEmailLogAsync(applicationId); + return emailLog.Id; + } + public async Task DeleteEmail(Guid id) { await emailNotificationManager.DeleteEmailLogAsync(id); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs index 19885e38e9..cc9fe5a31f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs @@ -32,7 +32,12 @@ public interface IEmailNotificationManager Task GetEmailLogByIdAsync(Guid id); /// - /// Deletes an email log + /// Creates an empty draft email log for composing + /// + Task CreateDraftEmailLogAsync(Guid applicationId); + + /// + /// Deletes an email log and its S3 attachments /// Task DeleteEmailLogAsync(Guid id); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs index b009f53fb1..c598fef948 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs @@ -20,6 +20,7 @@ public interface IEmailNotificationService : IApplicationService Task SendEmailToQueue(EmailLog emailLog); Task> GetHistoryByApplicationId(Guid applicationId); Task UpdateSettings(NotificationsSettingsDto settingsDto); + Task InitializeDraftAsync(Guid applicationId); Task DeleteEmail(Guid id); Task GetEmailsChesWithNoResponseCountAsync(); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs index 35068660d2..6a0172dd08 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; using Unity.Notifications.Emails; using Volo.Abp.DependencyInjection; @@ -14,6 +15,8 @@ namespace Unity.Notifications.EmailNotifications; public class EmailAttachmentService : ITransientDependency { + private const string S3BucketConfigKey = "S3:Bucket"; + private readonly AmazonS3Client _amazonS3Client; private readonly IEmailLogAttachmentRepository _emailLogAttachmentRepository; private readonly IConfiguration _configuration; @@ -55,7 +58,7 @@ public async Task UploadAttachmentAsync( string contentType) { var s3Key = BuildS3Key(tenantId, emailLogId, fileName); - var bucket = _configuration["S3:Bucket"]; + var bucket = _configuration[S3BucketConfigKey]; // Upload to S3 using var uploadStream = new MemoryStream(fileContent); @@ -94,7 +97,7 @@ public async Task UploadAttachmentAsync( public async Task DownloadFromS3Async(string s3ObjectKey) { - var bucket = _configuration["S3:Bucket"]; + var bucket = _configuration[S3BucketConfigKey]; var getObjectRequest = new GetObjectRequest { @@ -111,11 +114,82 @@ public async Task UploadAttachmentAsync( return memoryStream.ToArray(); } + public async Task UploadUserAttachmentAsync( + Guid emailLogId, + Guid? tenantId, + string fileName, + byte[] fileContent, + string contentType) + { + var uniqueKey = Guid.NewGuid(); + var s3Key = BuildUserAttachmentS3Key(tenantId, emailLogId, uniqueKey, fileName); + var bucket = _configuration[S3BucketConfigKey]; + + using var uploadStream = new MemoryStream(fileContent); + var putRequest = new PutObjectRequest + { + BucketName = bucket, + Key = s3Key, + ContentType = contentType, + InputStream = uploadStream, + UseChunkEncoding = false, + DisablePayloadSigning = false + }; + + await _amazonS3Client.PutObjectAsync(putRequest); + _logger.LogInformation( + "Uploaded user email attachment to S3: FileName={FileName}, FileSize={FileSize}", + fileName, fileContent.Length); + + var attachment = new EmailLogAttachment + { + EmailLogId = emailLogId, + S3ObjectKey = s3Key, + FileName = fileName, + DisplayName = fileName, + ContentType = contentType, + FileSize = fileContent.Length, + Time = DateTime.UtcNow, + UserId = _currentUser.Id ?? Guid.Empty, + TenantId = tenantId + }; + + await _emailLogAttachmentRepository.InsertAsync(attachment); + return attachment; + } + + public async Task DeleteFromS3Async(string s3ObjectKey) + { + var bucket = _configuration[S3BucketConfigKey]; + var deleteRequest = new DeleteObjectRequest + { + BucketName = bucket, + Key = s3ObjectKey + }; + await _amazonS3Client.DeleteObjectAsync(deleteRequest); + _logger.LogInformation("Deleted email attachment from S3."); + } + public async Task> GetAttachmentsAsync(Guid emailLogId) { return await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); } + public async Task GetTotalFileSizeAsync(Guid emailLogId) + { + var attachments = await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); + return attachments.Sum(a => a.FileSize); + } + + private static string BuildUserAttachmentS3Key(Guid? tenantId, Guid emailLogId, Guid attachmentId, string fileName) + { + var basePath = "Email/Attachments"; + var tenantPart = tenantId?.ToString() ?? "host"; + var escapedFileName = Uri.EscapeDataString(fileName); + + return $"{basePath}/{tenantPart}/{emailLogId}/{attachmentId}/{escapedFileName}"; + } + private static string BuildS3Key(Guid? tenantId, Guid emailLogId, string fileName) { var basePath = "Email/FSB-AP-Payments"; @@ -124,6 +198,4 @@ private static string BuildS3Key(Guid? tenantId, Guid emailLogId, string fileNam return $"{basePath}/{tenantPart}/{emailLogId}/{escapedFileName}"; } - - } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs new file mode 100644 index 0000000000..31a5a9b20b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs @@ -0,0 +1,105 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.Notifications.EmailNotifications; +using Unity.Notifications.Permissions; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Users; + +namespace Unity.Notifications.Emails; + +[Authorize(NotificationsPermissions.Email.Send)] +[ExposeServices(typeof(EmailLogAttachmentAppService), typeof(IEmailLogAttachmentAppService), typeof(IEmailLogAttachmentUploadService))] +public class EmailLogAttachmentAppService( + IEmailLogAttachmentRepository emailLogAttachmentRepository, + IEmailLogsRepository emailLogsRepository, + EmailAttachmentService emailAttachmentService, + IExternalUserLookupServiceProvider externalUserLookupServiceProvider) : ApplicationService, IEmailLogAttachmentAppService, IEmailLogAttachmentUploadService +{ + public async Task> GetListByEmailLogIdAsync(Guid emailLogId) + { + var attachments = await emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); + var dtos = new List(); + + foreach (var attachment in attachments) + { + dtos.Add(new EmailLogAttachmentDto + { + Id = attachment.Id, + FileName = attachment.FileName, + DisplayName = attachment.DisplayName, + Time = attachment.Time, + FileSize = attachment.FileSize, + ContentType = attachment.ContentType, + S3ObjectKey = attachment.S3ObjectKey, + AttachedBy = await ResolveUserNameAsync(attachment.UserId) + }); + } + + return dtos; + } + + public async Task DeleteAsync(Guid id) + { + var attachment = await emailLogAttachmentRepository.GetAsync(id); + + var emailLog = await emailLogsRepository.GetAsync(attachment.EmailLogId); + if (emailLog.Status != EmailStatus.Draft) + { + throw new UserFriendlyException("Attachments can only be deleted from draft emails."); + } + + try + { + await emailAttachmentService.DeleteFromS3Async(attachment.S3ObjectKey); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete S3 object {S3ObjectKey} for attachment {AttachmentId}", attachment.S3ObjectKey, id); + } + await emailLogAttachmentRepository.DeleteAsync(id); + } + + public async Task GetTotalFileSizeByEmailLogIdAsync(Guid emailLogId) + { + return await emailAttachmentService.GetTotalFileSizeAsync(emailLogId); + } + + public async Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType) + { + var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, tenantId, fileName, content, contentType); + + return new EmailLogAttachmentDto + { + Id = attachment.Id, + FileName = attachment.FileName, + DisplayName = attachment.DisplayName, + Time = attachment.Time, + FileSize = attachment.FileSize, + ContentType = attachment.ContentType, + S3ObjectKey = attachment.S3ObjectKey, + AttachedBy = await ResolveUserNameAsync(attachment.UserId) + }; + } + + private async Task ResolveUserNameAsync(Guid userId) + { + try + { + var user = await externalUserLookupServiceProvider.FindByIdAsync(userId); + if (user == null) return string.Empty; + + var fullName = $"{user.Name} {user.Surname}".Trim(); + return string.IsNullOrEmpty(fullName) ? user.UserName : fullName; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to resolve username for UserId {UserId}", userId); + return string.Empty; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln b/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln index 235b0b5753..59281f42db 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln +++ b/applications/Unity.GrantManager/modules/Unity.Payments/Unity.Payments.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.Web", "src\U EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.HttpApi.Client.ConsoleTestApp", "test\Unity.Payments.HttpApi.Client.ConsoleTestApp\Unity.Payments.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Payments.Installer", "src\Unity.Payments.Installer\Unity.Payments.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Payments.Installer", "src\Unity.Payments.Installer\Unity.Payments.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index be9999cd12..353515b315 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -134,7 +134,8 @@ public async Task> GetBatchPaymentRollupsByCor || (p.Status == PaymentRequestStatus.Submitted && string.IsNullOrEmpty(p.PaymentStatus) && (string.IsNullOrEmpty(p.InvoiceStatus) - || !p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas)))) + || (!p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas) + && !p.InvoiceStatus.Contains(CasPaymentRequestStatus.NotFound))))) .Sum(p => p.Amount) }) .ToListAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs index b2d4ee5872..d51c68030e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs @@ -1,23 +1,29 @@ +using System; using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; -using Unity.Payments.RabbitMQ.QueueMessages; -using System; -using Volo.Abp.MultiTenancy; using Unity.Payments.Integrations.Cas; +using Unity.Payments.RabbitMQ.QueueMessages; namespace Unity.Payments.Integrations.RabbitMQ; -public class InvoiceConsumer(InvoiceService invoiceService, - ICurrentTenant currentTenant) : IQueueConsumer +/// +/// Processes invoice creation messages from RabbitMQ. +/// Tenant context and audit scope are established by +/// before this consumer is invoked — no manual wiring needed here. +/// +public class InvoiceConsumer( + InvoiceService invoiceService +) : IQueueConsumer { public async Task ConsumeAsync(InvoiceMessages invoiceMessage) { - if (invoiceMessage != null && !invoiceMessage.InvoiceNumber.IsNullOrEmpty() && invoiceMessage.TenantId != Guid.Empty) + if (invoiceMessage == null || + invoiceMessage.InvoiceNumber.IsNullOrEmpty() || + invoiceMessage.TenantId == Guid.Empty) { - using (currentTenant.Change(invoiceMessage.TenantId)) - { - await invoiceService.CreateInvoiceByPaymentRequestAsync(invoiceMessage.InvoiceNumber); - } + return; } + + await invoiceService.CreateInvoiceByPaymentRequestAsync(invoiceMessage.InvoiceNumber); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs index 48ac9fb986..78b7824bee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs @@ -3,7 +3,7 @@ namespace Unity.Payments.RabbitMQ.QueueMessages { - public class InvoiceMessages : IQueueMessage + public class InvoiceMessages : ITenantedQueueMessage { public Guid MessageId { get; set; } public TimeSpan TimeToLive { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs index 7b7737d682..5798351f58 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs @@ -3,7 +3,7 @@ namespace Unity.Payments.RabbitMQ.QueueMessages { - public class ReconcilePaymentMessages : IQueueMessage + public class ReconcilePaymentMessages : ITenantedQueueMessage { public Guid MessageId { get; set; } public TimeSpan TimeToLive { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs index c462ae9b35..1fc39278ba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs @@ -1,40 +1,46 @@ +using System; using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; -using Unity.Payments.RabbitMQ.QueueMessages; -using System; -using Unity.Payments.PaymentRequests; using Unity.Payments.Integrations.Cas; -using Volo.Abp.MultiTenancy; +using Unity.Payments.PaymentRequests; +using Unity.Payments.RabbitMQ.QueueMessages; namespace Unity.Payments.Integrations.RabbitMQ; +/// +/// Processes payment reconciliation messages from RabbitMQ. +/// Tenant context and audit scope are established by +/// before this consumer is invoked — no manual wiring needed here. +/// public class ReconciliationConsumer( - CasPaymentRequestCoordinator casPaymentRequestCoordinator, - InvoiceService invoiceService, - ICurrentTenant currentTenant - ) : IQueueConsumer + CasPaymentRequestCoordinator casPaymentRequestCoordinator, + InvoiceService invoiceService +) : IQueueConsumer { public async Task ConsumeAsync(ReconcilePaymentMessages reconcilePaymentMessage) { - if (reconcilePaymentMessage != null && !reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() && reconcilePaymentMessage.TenantId != Guid.Empty) - { + if (reconcilePaymentMessage == null || + reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() || + reconcilePaymentMessage.TenantId == Guid.Empty) + { + return; + } - using (currentTenant.Change(reconcilePaymentMessage.TenantId)) - { - // string invoiceNumber, string supplierNumber, string siteNumber) - // Go to CAS retrieve the status of the payment - CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( - reconcilePaymentMessage.TenantId, - reconcilePaymentMessage.InvoiceNumber, - reconcilePaymentMessage.SupplierNumber, - reconcilePaymentMessage.SiteNumber); + // string invoiceNumber, string supplierNumber, string siteNumber) + // Go to CAS retrieve the status of the payment + CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( + reconcilePaymentMessage.TenantId, + reconcilePaymentMessage.InvoiceNumber, + reconcilePaymentMessage.SupplierNumber, + reconcilePaymentMessage.SiteNumber); - if (result != null && result.InvoiceStatus != null && result.InvoiceStatus != "") - { - await casPaymentRequestCoordinator.UpdatePaymentRequestStatus(reconcilePaymentMessage.TenantId, reconcilePaymentMessage.PaymentRequestId, result); - } - } + + if (!string.IsNullOrEmpty(result?.InvoiceStatus)) + { + await casPaymentRequestCoordinator.UpdatePaymentRequestStatus( + reconcilePaymentMessage.TenantId, + reconcilePaymentMessage.PaymentRequestId, + result); } } - } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index 7e2e287bec..a4698477af 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -15,28 +15,15 @@ namespace Unity.Payments.PaymentRequests { - public class CasPaymentRequestCoordinator : ApplicationService - { - private readonly IPaymentRequestRepository _paymentRequestsRepository; - private readonly IUnitOfWorkManager _unitOfWorkManager; - private readonly ITenantRepository _tenantRepository; - private readonly ICurrentTenant _currentTenant; - private readonly PaymentQueueService _paymentQueueService; - private static int TenMinutes = 10; - - public CasPaymentRequestCoordinator( - PaymentQueueService paymentQueueService, + public class CasPaymentRequestCoordinator(PaymentQueueService paymentQueueService, IPaymentRequestRepository paymentRequestsRepository, IUnitOfWorkManager unitOfWorkManager, ITenantRepository tenantRepository, - ICurrentTenant currentTenant) - { - _paymentQueueService = paymentQueueService; - _paymentRequestsRepository = paymentRequestsRepository; - _tenantRepository = tenantRepository; - _currentTenant = currentTenant; - _unitOfWorkManager = unitOfWorkManager; - } + ICurrentTenant currentTenant) : ApplicationService + { + + private static int TenMinutes = 10; + protected virtual dynamic GetPaymentRequestObject( Guid paymentRequestId, @@ -60,7 +47,7 @@ public async Task AddPaymentRequestsToInvoiceQueue(PaymentRequest paymentRequest { try { - if (!string.IsNullOrEmpty(paymentRequest.InvoiceNumber) && _currentTenant != null && _currentTenant.Id != null) + if (!string.IsNullOrEmpty(paymentRequest.InvoiceNumber) && currentTenant != null && currentTenant.Id != null) { InvoiceMessages message = new InvoiceMessages { @@ -69,10 +56,10 @@ public async Task AddPaymentRequestsToInvoiceQueue(PaymentRequest paymentRequest InvoiceNumber = paymentRequest.InvoiceNumber, SupplierNumber = paymentRequest.SupplierNumber, SiteNumber = paymentRequest.Site.Number, - TenantId = (Guid)_currentTenant.Id + TenantId = (Guid)currentTenant.Id }; - await _paymentQueueService.SendPaymentToInvoiceQueueAsync(message); + await paymentQueueService.SendPaymentToInvoiceQueueAsync(message); } } catch (Exception ex) @@ -93,21 +80,21 @@ public async Task ManuallyAddPaymentRequestsToReconciliationQueue(List tenant.Id)) { - using (_currentTenant.Change(tenantId)) + using (currentTenant.Change(tenantId)) { - List paymentRequests = await _paymentRequestsRepository.GetPaymentRequestsBySentToCasStatusAsync(); + List paymentRequests = await paymentRequestsRepository.GetPaymentRequestsBySentToCasStatusAsync(); foreach (PaymentRequest paymentRequest in paymentRequests) { ReconcilePaymentMessages reconcilePaymentMessage = new ReconcilePaymentMessages @@ -120,52 +107,60 @@ public async Task AddPaymentRequestsToReconciliationQueue() TenantId = tenantId }; - await _paymentQueueService.SendPaymentToReconciliationQueueAsync(reconcilePaymentMessage); + await paymentQueueService.SendPaymentToReconciliationQueueAsync(reconcilePaymentMessage); } } } } + /// + /// Updates payment request status from CAS integration results. + /// Tenant context and audit scope are already established by the caller + /// (via ); + /// this method only needs to own its unit of work. + /// public async Task UpdatePaymentRequestStatus(Guid TenantId, Guid PaymentRequestId, CasPaymentSearchResult result) { - PaymentRequest? paymentReqeust = null; - if (TenantId != Guid.Empty) + if (TenantId == Guid.Empty) { - using (_currentTenant.Change(TenantId)) - { - try - { - using var uow = _unitOfWorkManager.Begin(true, false); - paymentReqeust = await _paymentRequestsRepository.GetAsync(PaymentRequestId); - if (paymentReqeust != null) - { - if(paymentReqeust.InvoiceStatus == CasPaymentRequestStatus.NotFound && result.InvoiceStatus == CasPaymentRequestStatus.NotFound) - { - result.InvoiceStatus = CasPaymentRequestStatus.NotFound+"2"; - } - - paymentReqeust.SetInvoiceStatus(result.InvoiceStatus ?? ""); - paymentReqeust.SetPaymentStatus(result.PaymentStatus ?? ""); - paymentReqeust.SetPaymentDate(result.PaymentDate ?? ""); - paymentReqeust.SetPaymentNumber(result.PaymentNumber ?? ""); - if(result.InvoiceStatus != null) - { - paymentReqeust.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); - paymentReqeust.SetCasResponse("SUCCEEDED"); - } - - await _paymentRequestsRepository.UpdateAsync(paymentReqeust, autoSave: false); - await uow.SaveChangesAsync(); - } - } - catch (Exception ex) - { - string ExceptionMessage = ex.Message; - Logger.LogInformation(ex, "UpdatePaymentRequestStatus: Error updating payment request: {ExceptionMessage}", ExceptionMessage); - } - } + return null; + } + + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + + var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); + + UpdatePaymentRequestFromCasResult(paymentRequest, result); + + await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); + + // CompleteAsync commits the transaction and calls SaveChangesAsync, + // which triggers AbpDbContext to collect entity changes into the active audit log. + // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns. + await uow.CompleteAsync(); + + return paymentRequest; + } + + private static void UpdatePaymentRequestFromCasResult(PaymentRequest paymentRequest, CasPaymentSearchResult result) + { + // Handle duplicate NotFound status by appending "2" + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound && + result.InvoiceStatus == CasPaymentRequestStatus.NotFound) + { + result.InvoiceStatus = CasPaymentRequestStatus.NotFound + "2"; + } + + paymentRequest.SetInvoiceStatus(result.InvoiceStatus ?? ""); + paymentRequest.SetPaymentStatus(result.PaymentStatus ?? ""); + paymentRequest.SetPaymentDate(result.PaymentDate ?? ""); + paymentRequest.SetPaymentNumber(result.PaymentNumber ?? ""); + + if (result.InvoiceStatus != null) + { + paymentRequest.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); + paymentRequest.SetCasResponse("SUCCEEDED"); } - return paymentReqeust; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs index 46a506fb26..db352a16aa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs @@ -15,10 +15,12 @@ using Volo.Abp.Application.Dtos; using Volo.Abp.AspNetCore.ExceptionHandling; using Unity.Payments.PaymentRequests.Notifications; +using Unity.Modules.Shared.Auditing; namespace Unity.Payments; [DependsOn( + typeof(UnityAuditingOverrideModule), typeof(AbpVirtualFileSystemModule), typeof(AbpDddApplicationModule), typeof(AbpAutoMapperModule), diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index dbab087b04..dc9371b52e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -43,14 +43,30 @@ $(function () { { text: 'Check Status', className: 'custom-table-btn flex-none btn btn-secondary payment-check-status', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { + if (!dt.rows({ selected: true }).any() || !selectedPaymentIds || selectedPaymentIds.length === 0) { + abp.notify.info('No Payment Requests were selected for this action.') + return; + } + $.ajax({ url: '/api/app/payment-request/manually-add-payment-requests-to-reconciliation-queue', method: 'POST', contentType: 'application/json', data: JSON.stringify(selectedPaymentIds) }) - .done(() => abp.notify.success('The Status Check has been sent for verification to CFS. Please refresh this page to check for Status updates.')) + .done(() => { + abp.notify.success('The Status Check has been sent for verification to CFS. Please refresh this page to check for Status updates.'); + $(".select-all-payments").prop("checked", false); + payment_approve_buttons.disable(); + payment_check_status_buttons.disable(); + history_button.disable(); + selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); + }) .fail(() => abp.notify.error(l('Failed To Add To Reconciliation Queue'))); } }, @@ -733,8 +749,10 @@ $(function () { dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); payment_approve_buttons.disable(); - + payment_check_status_buttons.disable(); + history_button.disable(); selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); }); function getStatusTextColor(status) { @@ -785,6 +803,11 @@ $(function () { (msg, data) => { dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); + payment_approve_buttons.disable(); + payment_check_status_buttons.disable(); + history_button.disable(); + selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); PubSub.publish('clear_selected_payment'); } ); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js index 38e077e83b..d7ab43374f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js @@ -189,6 +189,7 @@ $(function () { 'Payment Tags' ); selectedPaymentIds = []; + manageActionButtons(); PubSub.publish("refresh_payment_list"); }); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index 33abb20eb9..36ca7e1c7f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -148,10 +148,10 @@ { text: 'Filter', className: 'custom-table-btn flex-none btn btn-secondary', - id: 'btn-toggle-filter', + id: 'btn-toggle-filter-payment-list', action: function (e, dt, node, config) {}, attr: { - id: 'btn-toggle-filter', + id: 'btn-toggle-filter-payment-list', }, }, { @@ -214,6 +214,7 @@ languageSetValues: {}, dataTableName: 'ApplicationPaymentRequestListTable', externalSearchId: 'PaymentListSearch', + externalFilterButtonId: 'btn-toggle-filter-payment-list', dynamicButtonContainerId: 'dynamicButtonContainerId', lengthMenu: [10, 25, 50, -1] }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs index 17a7da11f0..581b3d1e83 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs @@ -212,6 +212,31 @@ await InsertPaymentRequestAsync(siteId, correlationId, 300m, results[0].TotalPending.ShouldBe(800m); // 500 + 300 } + [Fact] + [Trait("Category", "Integration")] + public async Task Should_Exclude_Submitted_WithNotFound_InvoiceStatus_FromPending() + { + // Arrange + var correlationId = Guid.NewGuid(); + var siteId = await CreateSupplierAndSiteAsync(); + + using var uow = _unitOfWorkManager.Begin(); + // NotFound invoice status - should NOT be counted as pending + await InsertPaymentRequestAsync(siteId, correlationId, 1000m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "NotFound"); + // Valid pending - SHOULD be counted + await InsertPaymentRequestAsync(siteId, correlationId, 200m, + PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas"); + + // Act + var results = await _paymentRequestRepository + .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]); + + // Assert + results.Count.ShouldBe(1); + results[0].TotalPending.ShouldBe(200m); // Only the non-NotFound one + } + [Fact] [Trait("Category", "Integration")] public async Task Should_Exclude_Submitted_WithErrorFromCas_FromPending() diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln b/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln index 0bbebbee35..c14d0cca23 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/Unity.Reporting.sln @@ -35,7 +35,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.Web", "src\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.HttpApi.Client.ConsoleTestApp", "test\Unity.Reporting.HttpApi.Client.ConsoleTestApp\Unity.Reporting.HttpApi.Client.ConsoleTestApp.csproj", "{1EDCD6D4-DF3A-4E3B-ABB6-C0D0B373EAB8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.Reporting.Installer", "src\Unity.Reporting.Installer\Unity.Reporting.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Reporting.Installer", "src\Unity.Reporting.Installer\Unity.Reporting.Installer.csproj", "{BE39FD00-745B-4049-8161-FC129817CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs index 4b856b3af3..7372d461f9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/FieldsProviders/ScoresheetFieldsProvider.cs @@ -233,7 +233,7 @@ private sealed class ScoresheetMapping /// Gets or sets the metadata information associated with the mapping. /// Contains contextual information about scoresheets and other correlation-specific details. /// - public MapMetadataDto? Metadata { get; set; } + public MapMetadataDto? Metadata { get; set; } = new MapMetadataDto(); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs new file mode 100644 index 0000000000..eafd1acb3c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs @@ -0,0 +1,58 @@ +using Unity.Modules.Shared.Constants; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Timing; +using Volo.Abp.Users; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// Custom audit property setter that ensures background jobs have proper user context. +/// With proper BackgroundJobContext setup, ABP should populate most values automatically. +/// This provides a safety net fallback using reflection for readonly properties. +/// +public class BackgroundJobAuditPropertySetter : AuditPropertySetter, ITransientDependency +{ + public BackgroundJobAuditPropertySetter(ICurrentUser currentUser, ICurrentTenant currentTenant, IClock clock) + : base(currentUser, currentTenant, clock) + { + } + + public override void SetCreationProperties(object targetObject) + { + // Call base first to let ABP try to set properties + base.SetCreationProperties(targetObject); + + // If in background job context and ABP hasn't set creator, use background job user + if (BackgroundJobExecutionContext.IsActive && + targetObject is ICreationAuditedObject createdObject && + createdObject.CreatorId == null) + { + var propertyInfo = targetObject.GetType().GetProperty(nameof(ICreationAuditedObject.CreatorId)); + if (propertyInfo != null && propertyInfo.CanWrite) + { + propertyInfo.SetValue(targetObject, BackgroundJobConstants.BackgroundJobPersonId); + } + } + } + + public override void SetModificationProperties(object targetObject) + { + // Call base first to let ABP try to set properties + base.SetModificationProperties(targetObject); + + // If in background job context and ABP hasn't set modifier, use background job user + if (BackgroundJobExecutionContext.IsActive && + targetObject is IModificationAuditedObject modifiedObject && + modifiedObject.LastModifierId == null) + { + var propertyInfo = targetObject.GetType().GetProperty(nameof(IModificationAuditedObject.LastModifierId)); + if (propertyInfo != null && propertyInfo.CanWrite) + { + propertyInfo.SetValue(targetObject, BackgroundJobConstants.BackgroundJobPersonId); + } + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs new file mode 100644 index 0000000000..821b06ccc2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Modules.Shared.Constants; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// Custom auditing helper that forces audit logging for background job operations. +/// Wraps ABP's default AuditingHelper to intercept auditing decisions and ensure +/// EntityChanges are recorded even when no authenticated user is present. +/// +public class UnityAuditingHelper : IAuditingHelper, ITransientDependency +{ + private readonly AuditingHelper _inner; + + public UnityAuditingHelper(AuditingHelper inner) + { + _inner = inner; + } + + public bool ShouldSaveAudit(MethodInfo? methodInfo, bool defaultValue = false, bool ignoreIntegrationServiceAttribute = false) + { + // Force auditing for background jobs - bypass normal checks that fail when currentUser.Id is null + if (BackgroundJobExecutionContext.IsActive) + { + return true; + } + + return _inner.ShouldSaveAudit(methodInfo, defaultValue, ignoreIntegrationServiceAttribute); + } + + public bool IsEntityHistoryEnabled(Type entityType, bool defaultValue = false) + { + // Force entity history for background jobs - ensures EntityChanges table gets populated + if (BackgroundJobExecutionContext.IsActive) + { + return true; + } + + return _inner.IsEntityHistoryEnabled(entityType, defaultValue); + } + + public AuditLogInfo CreateAuditLogInfo() + { + var auditLogInfo = _inner.CreateAuditLogInfo(); + + // Enrich audit log with background job user when no authenticated user present + if (BackgroundJobExecutionContext.IsActive && auditLogInfo.UserId == null) + { + auditLogInfo.UserId = BackgroundJobConstants.BackgroundJobPersonId; + auditLogInfo.UserName = BackgroundJobConstants.BackgroundJobUserName; + } + + return auditLogInfo; + } + + public AuditLogActionInfo CreateAuditLogAction( + AuditLogInfo auditLog, + Type? type, + MethodInfo method, + object?[] arguments) + { + return _inner.CreateAuditLogAction(auditLog, type, method, arguments); + } + + public AuditLogActionInfo CreateAuditLogAction( + AuditLogInfo auditLog, + Type? type, + MethodInfo method, + IDictionary arguments) + { + return _inner.CreateAuditLogAction(auditLog, type, method, arguments); + } +} + diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs new file mode 100644 index 0000000000..0cd68880c4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Auditing; +using Volo.Abp.Modularity; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// ABP module that overrides default auditing behavior to support background job entity change tracking. +/// Registers custom implementations that force auditing when BackgroundJobExecutionContext is active. +/// +[DependsOn( + typeof(AbpAuditingModule) +)] +public class UnityAuditingOverrideModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + // Override audit property setter to handle readonly audit properties in background jobs + context.Services.Replace( + ServiceDescriptor.Transient() + ); + + // Override auditing helper to force entity change tracking for background jobs + context.Services.Replace( + ServiceDescriptor.Transient() + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs new file mode 100644 index 0000000000..43a106ad90 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.Modules.Shared.Constants; + +public static class BackgroundJobConstants +{ + // Well-known fixed GUID for the Background Job Execution Person record (one per tenant) + public static readonly Guid BackgroundJobPersonId = new("00000000-0000-0000-0000-000000000002"); + public const string BackgroundJobOidcSub = "unity-background-job"; + public const string BackgroundJobDisplayName = "Unity Background Job Execution"; + public const string BackgroundJobBadge = "BGJ"; + public const string BackgroundJobUserName = "UBGJ"; + public const string BackgroundJobName = "UnityBackgroundJob"; + public const string BackgroundJobEmail = "grantmanagementsupport@gov.bc.ca"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs new file mode 100644 index 0000000000..4aac91736e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces +{ + /// + /// Extends for messages that carry tenant context. + /// Implementing this interface causes + /// to automatically establish background-job auditing scope before invoking the consumer, + /// mirroring the way ASP.NET Core middleware wraps controller actions. + /// + public interface ITenantedQueueMessage : IQueueMessage + { + Guid TenantId { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index c94fc005af..d64d0a1248 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -8,6 +8,10 @@ using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Exceptions; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ { @@ -86,8 +90,21 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) _logger.LogInformation("Processing MessageId {MessageId}", message.MessageId); - var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); - await consumerInstance.ConsumeAsync(message); + if (message is not ITenantedQueueMessage tenantedMessage) + { + var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); + await consumerInstance.ConsumeAsync(message); + } + else if (tenantedMessage.TenantId == Guid.Empty) + { + _logger.LogError("Message {MessageId} on {Queue} has an empty TenantId and cannot be processed", message.MessageId, _queueName); + consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + return; + } + else + { + await ConsumeWithAuditingAsync(consumerScope, tenantedMessage, message); + } consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); @@ -105,6 +122,58 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) } } + /// + /// Wraps consumer execution in a background-job auditing scope, mirroring the way + /// ASP.NET Core middleware wraps controller actions. Tenant context, identity, and + /// audit persistence are handled here so individual consumers stay free of + /// infrastructure concerns. + /// + private static async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, ITenantedQueueMessage tenantedMessage, TQueueMessage message) + { + var auditingManager = consumerScope.ServiceProvider.GetRequiredService(); + var principalAccessor = consumerScope.ServiceProvider.GetRequiredService(); + var currentTenant = consumerScope.ServiceProvider.GetRequiredService(); + var auditingStore = consumerScope.ServiceProvider.GetRequiredService(); + + using (BackgroundJobExecutionContext.Use()) + using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantedMessage.TenantId)) + { + AddConsumerAuditAction(auditingManager, message); + + var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); + await consumerInstance.ConsumeAsync(message); + + // Persist audit log if the consumer produced any entity changes. + // Entity changes are collected by AbpDbContext.SaveChangesAsync() during UOW commit, + // so this call happens after the consumer's unit of work completes. + if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) + { + await auditingStore.SaveAsync(log); + } + } + } + + /// + /// Adds a single to the current audit scope using + /// reflection on the generic consumer type. ABP requires at least one recorded action + /// before it will persist an audit log with entity changes. + /// + private static void AddConsumerAuditAction(IAuditingManager auditingManager, TQueueMessage message) + { + if (auditingManager.Current?.Log == null) + { + return; + } + + auditingManager.Current.Log.Actions.Add(new AuditLogActionInfo + { + ServiceName = typeof(TMessageConsumer).FullName ?? typeof(TMessageConsumer).Name, + MethodName = nameof(IQueueConsumer.ConsumeAsync), + Parameters = System.Text.Json.JsonSerializer.Serialize(message), + ExecutionTime = DateTime.UtcNow + }); + } + private static TQueueMessage? DeserializeMessage(byte[] body) { var json = Encoding.UTF8.GetString(body); diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs new file mode 100644 index 0000000000..78ce8c6609 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -0,0 +1,96 @@ +using System; +using System.Security.Claims; +using Unity.Modules.Shared.Constants; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Utility for establishing proper execution context for background jobs and message consumers. +/// Sets up tenant, user identity, and audit scope required for entity change tracking. +/// +public static class BackgroundJobContext +{ + /// + /// Sets up complete background job execution context with auditing, tenant, and user identity. + /// CRITICAL: Must be called AFTER BackgroundJobExecutionContext.Use() to enable forced auditing. + /// + /// The auditing manager for creating audit scope + /// The current principal accessor for setting user identity + /// The current tenant accessor for setting tenant context + /// The tenant ID to set context for + /// Optional user ID. If null, uses BackgroundJobConstants.BackgroundJobPersonId + /// IDisposable that restores previous context when disposed (LIFO order) + public static IDisposable Set( + IAuditingManager auditingManager, + ICurrentPrincipalAccessor principalAccessor, + ICurrentTenant currentTenant, + Guid? tenantId, + Guid? userId = null) + { + var effectiveUserId = userId ?? BackgroundJobConstants.BackgroundJobPersonId; + + var claims = new[] + { + new Claim(AbpClaimTypes.UserId, effectiveUserId.ToString()), + new Claim(ClaimTypes.NameIdentifier, effectiveUserId.ToString()), // Standard claim for user ID + new Claim(AbpClaimTypes.UserName, BackgroundJobConstants.BackgroundJobUserName), + new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail), + new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()), + new Claim(AbpClaimTypes.Name, BackgroundJobConstants.BackgroundJobName) + }; + + // Create an authenticated identity (authenticationType must be non-null for IsAuthenticated to be true) + var identity = new ClaimsIdentity(claims, "BackgroundJob", AbpClaimTypes.UserName, AbpClaimTypes.Role); + var principal = new ClaimsPrincipal(identity); + + // CRITICAL: Set tenant and principal BEFORE starting audit scope + // This ensures ABP captures correct context when audit scope is created + var tenantDisposable = currentTenant.Change(tenantId); + var principalDisposable = principalAccessor.Change(principal); + + // NOW start auditing - it will see the correct tenant/user context + var auditingDisposable = auditingManager.BeginScope(); + + // Ensure the current audit log has the user ID set for entity change tracking + if (auditingManager.Current != null) + { + auditingManager.Current.Log.UserId = effectiveUserId; + auditingManager.Current.Log.UserName = BackgroundJobConstants.BackgroundJobUserName; + auditingManager.Current.Log.TenantId = tenantId; + } + + // Dispose in LIFO order: last registered is disposed first. + // audit scope closes first (while tenant/user context is still valid), + // then principal, then tenant. + return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable); + } + + /// + /// Private helper class to combine multiple disposables into one + /// + private sealed class CompositeDisposable : IDisposable + { + private readonly IDisposable[] _disposables; + private bool _disposed; + + public CompositeDisposable(params IDisposable[] disposables) + { + _disposables = disposables; + } + + public void Dispose() + { + if (!_disposed) + { + for (int i = _disposables.Length - 1; i >= 0; i--) + { + _disposables[i]?.Dispose(); + } + _disposed = true; + } + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs new file mode 100644 index 0000000000..ceb635bbb0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Context marker for background job execution that survives async boundaries. +/// Used by auditing overrides to detect when code is running in a background job. +/// +public static class BackgroundJobExecutionContext +{ + private static readonly AsyncLocal _isActive = new(); + + /// + /// Returns true if currently executing within a background job context. + /// + public static bool IsActive => _isActive.Value; + + /// + /// Marks the current async context as executing within a background job. + /// Returns an IDisposable that clears the marker when disposed. + /// + /// IDisposable to clear the background job context + public static IDisposable Use() + { + bool previous = _isActive.Value; + _isActive.Value = true; + return new DisposeAction(() => _isActive.Value = previous); + } + + private sealed class DisposeAction : IDisposable + { + private readonly Action _action; + private bool _disposed; + + public DisposeAction(Action action) + { + _action = action; + } + + public void Dispose() + { + if (!_disposed) + { + _action?.Invoke(); + _disposed = true; + } + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js index 10a02a00dc..f897b99dda 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/table-utils.js @@ -53,6 +53,9 @@ if ($.fn.dataTable !== undefined && $.fn.dataTable.ext) { return csv; }, }; + + $.fn.dataTable.Buttons.defaults.dom.button.className = 'btn custom-table-btn flex-none'; + $.fn.dataTable.Buttons.defaults.dom.button.liner.tag = false; } // ============================================================================ @@ -183,6 +186,7 @@ if ($.fn.dataTable !== undefined && $.fn.dataTable.Api) { * @param {string} options.dynamicButtonContainerId - DOM ID where buttons are rendered * @param {boolean} [options.useNullPlaceholder=false] - Replace nulls with placeholder character * @param {string} [options.externalSearchId='search'] - ID of external search input element + * @param {string} [options.externalFilterButtonId='btn-toggle-filter'] - ID of external filter button element * @param {boolean} [options.disableColumnSelect=false] - Disable column visibility toggle * @param {Array} [options.listColumnDefs] - Additional columnDefs configurations * @param {Function} [options.onStateSaveParams] - Hook for additional state save parameters @@ -218,6 +222,7 @@ function initializeDataTable(options) { dynamicButtonContainerId, useNullPlaceholder = false, externalSearchId = 'search', + externalFilterButtonId='btn-toggle-filter', disableColumnSelect = false, listColumnDefs, onStateSaveParams, //External hooks for save/load/loaded @@ -421,7 +426,7 @@ function initializeDataTable(options) { let iDt = new DataTable(dt, configuration); // Initialize FilterRow plugin - initializeFilterRowPlugin(iDt); + initializeFilterRowPlugin(iDt, externalFilterButtonId); // Move buttons to designated container moveButtonsToContainer(iDt, updatedActionButtons, dynamicButtonContainerId); @@ -636,12 +641,13 @@ function adjustColumnsWithRetry(api) { /** * Initializes FilterRow plugin if available and button exists. * @param {DataTable} iDt - DataTable instance + * @param {string} externalFilterButtonId - ID of external filter button element, skips initialization if null */ -function initializeFilterRowPlugin(iDt) { - if (!$('#btn-toggle-filter').length) return; +function initializeFilterRowPlugin(iDt, externalFilterButtonId) { + if (!externalFilterButtonId || !$('#' + externalFilterButtonId).length) return; if ($.fn.dataTable?.FilterRow) { const filterRow = new $.fn.dataTable.FilterRow(iDt.settings()[0], { - buttonId: 'btn-toggle-filter', + buttonId: externalFilterButtonId, buttonText: FilterDesc.Default, buttonTextActive: FilterDesc.With_Filter, enablePopover: $.fn.popover !== undefined, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs new file mode 100644 index 0000000000..7829f8028b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJobArgs.cs @@ -0,0 +1,10 @@ +using System; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateApplicationAnalysisBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + public string? PromptVersion { get; set; } + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs new file mode 100644 index 0000000000..234f8ec706 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJobArgs.cs @@ -0,0 +1,10 @@ +using System; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateApplicationScoringBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + public string? PromptVersion { get; set; } + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs new file mode 100644 index 0000000000..7836e5abe3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJobArgs.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateAttachmentSummaryBackgroundJobArgs +{ + public List AttachmentIds { get; set; } = []; + public string? PromptVersion { get; set; } + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateContentBackgroundJobArgs.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateContentBackgroundJobArgs.cs new file mode 100644 index 0000000000..d320bf8316 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/BackgroundJobs/GenerateContentBackgroundJobArgs.cs @@ -0,0 +1,10 @@ +using System; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateContentBackgroundJobArgs +{ + public Guid ApplicationId { get; set; } + public string? PromptVersion { get; set; } + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/ITextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Extraction/ITextExtractionService.cs similarity index 81% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/ITextExtractionService.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Extraction/ITextExtractionService.cs index 22f34e292f..1e68280b99 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/ITextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Extraction/ITextExtractionService.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Extraction { public interface ITextExtractionService { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs index 160f8ed233..d059187316 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/IAIService.cs @@ -1,5 +1,6 @@ -using System.Collections.Generic; using System.Threading.Tasks; +using Unity.GrantManager.AI.Requests; +using Unity.GrantManager.AI.Responses; namespace Unity.GrantManager.AI { @@ -9,14 +10,7 @@ public interface IAIService Task GenerateCompletionAsync(AICompletionRequest request); Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request); - Task GenerateAttachmentSummaryAsync(string fileName, byte[] fileContent, string contentType); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request); - Task GenerateScoresheetSectionAnswersAsync(ScoresheetSectionRequest request); - Task GenerateScoresheetSectionAnswersAsync(string applicationContent, List attachmentSummaries, string sectionJson, string sectionName); - - // Legacy compatibility methods retained until flow orchestration refactor. - Task GenerateSummaryAsync(string content, string? prompt = null, int maxTokens = 150); - Task AnalyzeApplicationAsync(string applicationContent, List attachmentSummaries, string rubric, string? formFieldConfiguration = null); - Task GenerateScoresheetAnswersAsync(string applicationContent, List attachmentSummaries, string scoresheetQuestions); + Task GenerateApplicationScoringAsync(ApplicationScoringRequest request); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/AIAttachmentItem.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/AIAttachmentItem.cs index fc4b31e2a9..58c7c1c65e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/AIAttachmentItem.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/AIAttachmentItem.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Models { public class AIAttachmentItem { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/AIJsonKeys.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/AIJsonKeys.cs similarity index 69% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/AIJsonKeys.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/AIJsonKeys.cs index 5ebbf4df9b..fb2230cba5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/AIJsonKeys.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/AIJsonKeys.cs @@ -1,4 +1,4 @@ -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Models { public static class AIJsonKeys { @@ -6,7 +6,10 @@ public static class AIJsonKeys public const string Errors = "errors"; public const string Warnings = "warnings"; public const string Summaries = "summaries"; - public const string Dismissed = "dismissed"; + public const string NextSteps = "nextSteps"; + public const string Hidden = "hidden"; + public const string Recommendation = "recommendation"; + public const string Decision = "decision"; public const string Id = "id"; public const string Title = "title"; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs index 79785fee59..47baf9bca1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisFinding.cs @@ -1,12 +1,15 @@ using System.Text.Json.Serialization; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Models { public class ApplicationAnalysisFinding { [JsonPropertyName(AIJsonKeys.Id)] public string? Id { get; set; } + [JsonPropertyName(AIJsonKeys.Hidden)] + public bool Hidden { get; set; } + [JsonPropertyName(AIJsonKeys.Title)] public string? Title { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs new file mode 100644 index 0000000000..c84a3d4793 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationAnalysisRecommendation.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Unity.GrantManager.AI.Models +{ + public class ApplicationAnalysisRecommendation + { + [JsonPropertyName(AIJsonKeys.Decision)] + public string? Decision { get; set; } + + [JsonPropertyName(AIJsonKeys.Rationale)] + public string? Rationale { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ScoresheetSectionAnswer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationScoringAnswer.cs similarity index 82% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ScoresheetSectionAnswer.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationScoringAnswer.cs index 0a76cbb0e0..7796460557 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ScoresheetSectionAnswer.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Models/ApplicationScoringAnswer.cs @@ -1,9 +1,9 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Models { - public class ScoresheetSectionAnswer + public class ApplicationScoringAnswer { [JsonPropertyName(AIJsonKeys.Answer)] public JsonElement Answer { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs index 2ec1bf8d30..56dbc6fc4d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AICompletionRequest.cs @@ -1,15 +1,12 @@ using System.Text.Json.Serialization; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Requests { public class AICompletionRequest { [JsonPropertyName("userPrompt")] public string UserPrompt { get; set; } = string.Empty; - [JsonPropertyName("systemPrompt")] - public string? SystemPrompt { get; set; } - [JsonPropertyName("maxTokens")] public int MaxTokens { get; set; } = 150; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs index fcd809b8b8..5366b29877 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationAnalysisRequest.cs @@ -1,8 +1,9 @@ using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; +using Unity.GrantManager.AI.Models; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Requests { public class ApplicationAnalysisRequest { @@ -15,7 +16,7 @@ public class ApplicationAnalysisRequest [JsonPropertyName("attachments")] public List Attachments { get; set; } = new(); - [JsonPropertyName("rubric")] - public string? Rubric { get; set; } + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationScoringRequest.cs similarity index 70% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationScoringRequest.cs index 870412d079..241a46a977 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ScoresheetSectionRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/ApplicationScoringRequest.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Serialization; +using Unity.GrantManager.AI.Models; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Requests { - public class ScoresheetSectionRequest + public class ApplicationScoringRequest { [JsonPropertyName("data")] public JsonElement Data { get; set; } @@ -17,5 +18,8 @@ public class ScoresheetSectionRequest [JsonPropertyName("sectionSchema")] public JsonElement SectionSchema { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs index c0e1bfd1ee..4f4d009e86 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Requests/AttachmentSummaryRequest.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Requests { public class AttachmentSummaryRequest { @@ -12,5 +12,8 @@ public class AttachmentSummaryRequest [JsonPropertyName("contentType")] public string ContentType { get; set; } = "application/octet-stream"; + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AICompletionResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AICompletionResponse.cs index 316d2ef162..a146c7f86a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AICompletionResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AICompletionResponse.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Responses { public class AICompletionResponse { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs index e8a5afa194..fda17e43d6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationAnalysisResponse.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using Unity.GrantManager.AI.Models; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Responses { public class ApplicationAnalysisResponse { @@ -17,7 +18,10 @@ public class ApplicationAnalysisResponse [JsonPropertyName(AIJsonKeys.Summaries)] public List Summaries { get; set; } = new(); - [JsonPropertyName(AIJsonKeys.Dismissed)] - public List Dismissed { get; set; } = new(); + [JsonPropertyName(AIJsonKeys.NextSteps)] + public List NextSteps { get; set; } = new(); + + [JsonPropertyName(AIJsonKeys.Recommendation)] + public ApplicationAnalysisRecommendation? Recommendation { get; set; } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationScoringResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationScoringResponse.cs new file mode 100644 index 0000000000..3a5ce46501 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ApplicationScoringResponse.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Unity.GrantManager.AI.Models; + +namespace Unity.GrantManager.AI.Responses +{ + public class ApplicationScoringResponse + { + [JsonPropertyName("answers")] + public Dictionary Answers { get; set; } = new(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AttachmentSummaryResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AttachmentSummaryResponse.cs index 4f30b8c44a..170345cad1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AttachmentSummaryResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AttachmentSummaryResponse.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; +using Unity.GrantManager.AI.Models; -namespace Unity.GrantManager.AI +namespace Unity.GrantManager.AI.Responses { public class AttachmentSummaryResponse { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ScoresheetSectionResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ScoresheetSectionResponse.cs deleted file mode 100644 index cf4569dd07..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/ScoresheetSectionResponse.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Unity.GrantManager.AI -{ - public class ScoresheetSectionResponse - { - [JsonPropertyName("answers")] - public Dictionary Answers { get; set; } = new(); - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs index a1d9332cdf..1fa30a78a0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateFundingHistoryDto.cs @@ -6,7 +6,7 @@ public class CreateUpdateFundingHistoryDto { public Guid? ApplicantId { get; set; } public string? GrantCategory { get; set; } - public int? FundingYear { get; set; } + public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } public decimal? ReconsiderationAmount { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateIssueTrackingDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateIssueTrackingDto.cs index a7aaae5e80..8e5f5f9c40 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateIssueTrackingDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/CreateUpdateIssueTrackingDto.cs @@ -5,7 +5,7 @@ namespace Unity.GrantManager.ApplicantProfile; public class CreateUpdateIssueTrackingDto { public Guid? ApplicantId { get; set; } - public int? Year { get; set; } + public string? Year { get; set; } public string? IssueHeading { get; set; } public string? IssueDescription { get; set; } public bool? Resolved { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs index cdb292ba68..786f11bd48 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/FundingHistoryDto.cs @@ -7,7 +7,7 @@ public class FundingHistoryDto : AuditedEntityDto { public Guid? ApplicantId { get; set; } public string? GrantCategory { get; set; } - public int? FundingYear { get; set; } + public string? FundingYear { get; set; } public bool? RenewedFunding { get; set; } public decimal? ApprovedAmount { get; set; } public decimal? ReconsiderationAmount { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/IssueTrackingDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/IssueTrackingDto.cs index 4c2fb42e33..8305f1e27e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/IssueTrackingDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/History/IssueTrackingDto.cs @@ -6,7 +6,7 @@ namespace Unity.GrantManager.ApplicantProfile; public class IssueTrackingDto : AuditedEntityDto { public Guid? ApplicantId { get; set; } - public int? Year { get; set; } + public string? Year { get; set; } public string? IssueHeading { get; set; } public string? IssueDescription { get; set; } public bool? Resolved { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs index 4a99135f3d..4ca718a427 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantOrgInfoDto.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; + namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantOrgInfoDto : ApplicantProfileDataDto { public override string DataType => "ORGINFO"; + + public List Organizations { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs index c17c89eebe..3f4b70a7fc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/ApplicantPaymentInfoDto.cs @@ -1,7 +1,11 @@ +using System.Collections.Generic; + namespace Unity.GrantManager.ApplicantProfile.ProfileData { public class ApplicantPaymentInfoDto : ApplicantProfileDataDto { public override string DataType => "PAYMENTINFO"; + + public List Payments { get; set; } = []; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs new file mode 100644 index 0000000000..f5ef23aac4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/OrgInfoItemDto.cs @@ -0,0 +1,19 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class OrgInfoItemDto + { + public Guid Id { get; set; } + public string? OrgName { get; set; } + public string? OrganizationType { get; set; } + public string? OrgNumber { get; set; } + public string? OrgStatus { get; set; } + public string? NonRegOrgName { get; set; } + public string? FiscalMonth { get; set; } + public int? FiscalDay { get; set; } + public string? OrganizationSize { get; set; } + public string? Sector { get; set; } + public string? SubSector { get; set; } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/PaymentInfoItemDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/PaymentInfoItemDto.cs new file mode 100644 index 0000000000..da82267916 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/ApplicantProfile/ProfileData/PaymentInfoItemDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace Unity.GrantManager.ApplicantProfile.ProfileData +{ + public class PaymentInfoItemDto + { + public Guid Id { get; set; } + public string PaymentNumber { get; set; } = string.Empty; + public string ReferenceNo { get; set; } = string.Empty; + public decimal Amount { get; set; } + public string? PaymentDate { get; set; } + public string PaymentStatus { get; set; } = string.Empty; + } +} 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 bc1b54c02f..38b7855fdd 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 @@ -9,7 +9,7 @@ public class SubmissionInfoItemDto public DateTime ReceivedTime { get; set; } public DateTime SubmissionTime { get; set; } public string ReferenceNo { get; set; } = string.Empty; - public string ProjectName { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs index dd5f22e79a..97eafb6f4d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs @@ -31,5 +31,5 @@ public class ApplicantListDto : AuditedEntityDto public string? SupplierId { get; set; } public Guid? SiteId { get; set; } public decimal? MatchPercentage { get; set; } - public bool? IsDuplicated { get; set; } + public bool IsDuplicated { get; set; } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/ApplicationChefsFileAttachmentDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/ApplicationChefsFileAttachmentDto.cs index 9e9c9c3b12..fd2555acec 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/ApplicationChefsFileAttachmentDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/ApplicationChefsFileAttachmentDto.cs @@ -3,10 +3,13 @@ namespace Unity.GrantManager.Attachments; -public class ApplicationChefsFileAttachmentDto : EntityDto -{ - public Guid ApplicationId { get; set; } - public string ChefsSubmissionId { get; set; } = string.Empty; - public string ChefsFileId { get; set; } = string.Empty; - public string? Name { get; set; } -} +public class ApplicationChefsFileAttachmentDto : EntityDto +{ + public Guid ApplicationId { get; set; } + public string ChefsSubmissionId { get; set; } = string.Empty; + public string ChefsFileId { get; set; } = string.Empty; + public string? Name { get; set; } + public string? AISummary { get; set; } + public DateTime CreatedTime { get; set; } + public DateTime? UpdatedTime { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs index 3bf233769b..1b638f3bc3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentAppService.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System; using System.Threading.Tasks; using Volo.Abp.Application.Services; @@ -6,13 +6,11 @@ namespace Unity.GrantManager.Attachments; public interface IAttachmentAppService : IApplicationService -{ +{ Task> GetApplicationAsync(Guid applicationId); Task> GetAssessmentAsync(Guid assessmentId); Task ResyncSubmissionAttachmentsAsync(Guid applicationId); Task> GetAttachmentsAsync(AttachmentParametersDto attachmentParametersDto); Task GetAttachmentMetadataAsync(AttachmentType attachmentType, Guid attachmentId); Task UpdateAttachmentMetadataAsync(UpdateAttachmentMetadataDto updateAttachment); - Task GenerateAISummaryAttachmentAsync(Guid attachmentId); - Task> GenerateAISummariesAttachmentsAsync(List attachmentIds); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs new file mode 100644 index 0000000000..65589ed840 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Attachments/IAttachmentSummaryAppService.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.Attachments; + +public interface IAttachmentSummaryAppService : IApplicationService +{ + Task GenerateAttachmentSummaryAsync(Guid attachmentId, string? promptVersion = null); + Task> GenerateAttachmentSummariesAsync(List attachmentIds, string? promptVersion = null); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs index 330c2551fb..c8d35e6141 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs @@ -22,4 +22,5 @@ public class ApplicantSummaryDto public string? FiscalDay { get; set; } public string? FiscalMonth { get; set; } public string? ElectoralDistrict { get; set; } + public bool IsDuplicated { 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 f2906d1166..1dda8c6696 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 @@ -1,6 +1,6 @@ -using System; +using System; using System.Collections.Generic; -using Unity.GrantManager.AI; +using Unity.GrantManager.AI.Responses; using Unity.GrantManager.ApplicationForms; using Volo.Abp.Application.Dtos; @@ -85,4 +85,5 @@ public class GrantApplicationDto : AuditedEntityDto public string? ApplicantElectoralDistrict { get; set; } public string? AIAnalysis { get; set; } public ApplicationAnalysisResponse? AIAnalysisData { get; set; } + public string? AIScoresheetAnswers { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs index 22434446ff..9a4381db84 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs @@ -8,5 +8,6 @@ public class GrantApplicationLiteDto : AuditedEntityDto public string ProjectName { get; set; } = string.Empty; public string ReferenceNo { get; set; } = string.Empty; public string ApplicantName { get; set; } = string.Empty; - + public string OrganizationName { get; set; } = string.Empty; + public string UnityApplicantId { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAnalysisAppService.cs new file mode 100644 index 0000000000..493eb82c56 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationAnalysisAppService.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IApplicationAnalysisAppService : IApplicationService + { + Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationContentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationContentAppService.cs new file mode 100644 index 0000000000..c2d27129a6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationContentAppService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.GrantApplications; + +public interface IApplicationContentAppService : IApplicationService +{ + Task GenerateContentAsync(Guid applicationId, string? promptVersion = null); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs new file mode 100644 index 0000000000..dae0bc4fb7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IApplicationScoringAppService.cs @@ -0,0 +1,11 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.GrantApplications +{ + public interface IApplicationScoringAppService : IApplicationService + { + Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs index d02254aadb..2491deb1c9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/IGrantApplicationAppService.cs @@ -4,23 +4,23 @@ using Unity.Modules.Shared; using Volo.Abp.Application.Dtos; -namespace Unity.GrantManager.GrantApplications +namespace Unity.GrantManager.GrantApplications; + +public interface IGrantApplicationAppService { - public interface IGrantApplicationAppService - { - Task GetApplicationStatusAsync(Guid id); - Task> GetActions(Guid applicationId, bool includeInternal = false); - Task UpdateProjectInfoAsync(Guid id, CreateUpdateProjectInfoDto input); - Task UpdatePartialProjectInfoAsync(Guid id, PartialUpdateDto input); - Task UpdateAssessmentResultsAsync(Guid id, CreateUpdateAssessmentResultsDto input); - Task UpdateSupplierNumberAsync(Guid applicationId, string supplierNumber); - Task> GetAllApplicationsAsync(); - Task> GetApplicationDetailsListAsync(List applicationIds); - Task GetAsync(Guid id); - Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); - Task GetAccountCodingIdFromFormIdAsync(Guid formId); - Task DismissAIIssueAsync(Guid applicationId, string issueId); - Task RestoreAIIssueAsync(Guid applicationId, string issueId); - Task> GetListAsync(GrantApplicationListInputDto input); - } + Task GetApplicationStatusAsync(Guid id); + Task> GetActions(Guid applicationId, bool includeInternal = false); + Task UpdateProjectInfoAsync(Guid id, CreateUpdateProjectInfoDto input); + Task UpdatePartialProjectInfoAsync(Guid id, PartialUpdateDto input); + Task UpdateAssessmentResultsAsync(Guid id, CreateUpdateAssessmentResultsDto input); + Task UpdateSupplierNumberAsync(Guid applicationId, string supplierNumber); + Task> GetAllApplicationsAsync(); + Task> GetApplicationDetailsListAsync(List applicationIds); + Task GetAsync(Guid id); + Task TriggerAction(Guid applicationId, GrantApplicationAction triggerAction); + Task GetAccountCodingIdFromFormIdAsync(Guid formId); + Task HideAIAnalysisItemAsync(Guid applicationId, string itemId); + Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId); + Task> GetListAsync(GrantApplicationListInputDto input); + Task IsApplicantRedStopAsync(Guid applicationId); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs index 0c6b77a823..37ef649a82 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs @@ -1,9 +1,11 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; namespace Unity.GrantManager.Emails { public interface IEmailAppService { Task CreateAsync(CreateEmailDto dto); + Task InitializeDraftAsync(Guid applicationId); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs index 710a9bfca0..5dae52cf3e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs @@ -1,7 +1,6 @@ using Unity.GrantManager.Localization; using Unity.Modules.Shared; using Volo.Abp.Authorization.Permissions; -using Volo.Abp.Features; using Volo.Abp.Localization; using Volo.Abp.SettingManagement; @@ -115,32 +114,7 @@ public override void Define(IPermissionDefinitionContext context) //-- TAG ASSIGNMENT var tagsPermissionsGroup = context.AddGroup("Tags", L("Permission:Tags")); tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Create, L(UnitySelector.Application.Tags.Create)); - tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Delete, L(UnitySelector.Application.Tags.Delete)); - - // AI Permission Group - var aiPermissionsGroup = context.AddGroup( - GrantApplicationPermissions.AI.GroupName, - L("Permission:AI")); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.Reporting.Default, - L("Permission:AI.Reporting")) - .RequireFeatures("Unity.AIReporting"); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.ApplicationAnalysis.Default, - L("Permission:AI.ApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.AttachmentSummary.Default, - L("Permission:AI.AttachmentSummary")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); - - aiPermissionsGroup.AddPermission( - GrantApplicationPermissions.AI.ScoringAssistant.Default, - L("Permission:AI.ScoringAssistant")) - .RequireFeatures("Unity.AI.Scoring"); + tagsPermissionsGroup.AddPermission(UnitySelector.Application.Tags.Delete, L(UnitySelector.Application.Tags.Delete)); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/IWorksheetConfigurationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/IWorksheetConfigurationAppService.cs new file mode 100644 index 0000000000..05b0159aec --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/IWorksheetConfigurationAppService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.GrantManager.SettingManagement; + +public interface IWorksheetConfigurationAppService : IApplicationService +{ + Task GetDeletionCheckAsync(Guid worksheetId); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/WorksheetDeletionCheckDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/WorksheetDeletionCheckDto.cs new file mode 100644 index 0000000000..0ff346c41b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/SettingManagement/WorksheetDeletionCheckDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Unity.GrantManager.SettingManagement; + +public class WorksheetDeletionCheckDto +{ + public List BlockingFormNames { get; set; } = []; + public List LinkedFormNames { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJob.cs new file mode 100644 index 0000000000..e9bd6ee84b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationAnalysisBackgroundJob.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using Unity.GrantManager.AI.Operations; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateApplicationAnalysisBackgroundJob( + IApplicationAnalysisService applicationAnalysisService, + ICurrentTenant currentTenant, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateApplicationAnalysisBackgroundJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + logger.LogInformation("Executing AI application analysis background job for application {ApplicationId}.", args.ApplicationId); + await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJob.cs new file mode 100644 index 0000000000..a445993a68 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateApplicationScoringBackgroundJob.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Unity.GrantManager.AI.Operations; +using Unity.GrantManager.Intakes.Events; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus.Local; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateApplicationScoringBackgroundJob( + IApplicationScoringService applicationScoringService, + ILocalEventBus localEventBus, + ICurrentTenant currentTenant, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateApplicationScoringBackgroundJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + logger.LogInformation("Executing AI application scoring background job for application {ApplicationId}.", args.ApplicationId); + + var result = await applicationScoringService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + if (!string.Equals(result, "{}", StringComparison.Ordinal)) + { + await localEventBus.PublishAsync(new AIApplicationScoringGeneratedEvent + { + ApplicationId = args.ApplicationId + }); + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJob.cs new file mode 100644 index 0000000000..ed6a1ebd91 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateAttachmentSummaryBackgroundJob.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using Unity.GrantManager.AI.Operations; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateAttachmentSummaryBackgroundJob( + IAttachmentSummaryService attachmentSummaryService, + ICurrentTenant currentTenant, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateAttachmentSummaryBackgroundJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + logger.LogInformation( + "Executing AI attachment summary background job for {AttachmentCount} attachment(s).", + args.AttachmentIds.Count); + + await attachmentSummaryService.GenerateAndSaveAsync(args.AttachmentIds, args.PromptVersion); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs new file mode 100644 index 0000000000..2e55de9895 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/BackgroundJobs/GenerateContentBackgroundJob.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Threading.Tasks; +using Unity.AI.Settings; +using Unity.GrantManager.AI.Operations; +using Unity.GrantManager.Intakes.Events; +using Volo.Abp.BackgroundJobs; +using Volo.Abp.DependencyInjection; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Features; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Settings; + +namespace Unity.GrantManager.AI.BackgroundJobs; + +public class GenerateContentBackgroundJob( + IAttachmentSummaryService attachmentSummaryService, + IApplicationAnalysisService applicationAnalysisService, + IApplicationScoringService applicationScoringService, + IAIService aiService, + IFeatureChecker featureChecker, + ISettingProvider settingProvider, + ILocalEventBus localEventBus, + ICurrentTenant currentTenant, + ILogger logger) : AsyncBackgroundJob, ITransientDependency +{ + public override async Task ExecuteAsync(GenerateContentBackgroundJobArgs args) + { + using (currentTenant.Change(args.TenantId)) + { + var attachmentSummariesEnabled = await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries"); + var applicationAnalysisEnabled = await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis"); + var scoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring"); + + if (scoringEnabled) + { + scoringEnabled = await settingProvider.GetAsync(AISettings.ScoringAssistantEnabled, defaultValue: false); + } + + if (!attachmentSummariesEnabled && !applicationAnalysisEnabled && !scoringEnabled) + { + logger.LogDebug("All AI features are disabled, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); + return; + } + + if (!await aiService.IsAvailableAsync()) + { + logger.LogWarning("AI service is not available, skipping queued AI generation for application {ApplicationId}.", args.ApplicationId); + return; + } + + logger.LogInformation("Executing queued AI content pipeline for application {ApplicationId}.", args.ApplicationId); + + if (attachmentSummariesEnabled) + { + await attachmentSummaryService.GenerateForApplicationAsync(args.ApplicationId, args.PromptVersion); + } + + Exception? analysisException = null; + Exception? scoringException = null; + + if (applicationAnalysisEnabled) + { + try + { + await applicationAnalysisService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + } + catch (Exception ex) + { + analysisException = ex; + logger.LogError(ex, "Error executing AI application analysis stage for application {ApplicationId}.", args.ApplicationId); + } + } + + if (scoringEnabled) + { + try + { + var result = await applicationScoringService.RegenerateAndSaveAsync(args.ApplicationId, args.PromptVersion); + if (!string.Equals(result, "{}", StringComparison.Ordinal)) + { + await localEventBus.PublishAsync(new AIApplicationScoringGeneratedEvent + { + ApplicationId = args.ApplicationId + }); + } + } + catch (Exception ex) + { + scoringException = ex; + logger.LogError(ex, "Error executing AI application scoring stage for application {ApplicationId}.", args.ApplicationId); + } + } + + if (scoringException != null) + { + throw scoringException; + } + + if (analysisException != null) + { + throw analysisException; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Extraction/TextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Extraction/TextExtractionService.cs new file mode 100644 index 0000000000..a29d4cad30 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Extraction/TextExtractionService.cs @@ -0,0 +1,749 @@ +using Microsoft.Extensions.Logging; +using NPOI.SS.UserModel; +using NPOI.XWPF.UserModel; +using System; +using System.IO; +using System.IO.Compression; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Xml.Linq; +using UglyToad.PdfPig; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI.Extraction +{ + public partial class TextExtractionService : ITextExtractionService, ITransientDependency + { + private const int MaxExtractedTextLength = 50000; + private const int MaxExcelSheets = 10; + private const int MaxExcelRowsPerSheet = 2000; + private const int MaxExcelCellsPerRow = 50; + private const int MaxDocxParagraphs = 2000; + private const int MaxDocxTableRows = 2000; + private const int MaxDocxTableCellsPerRow = 50; + private const int MaxPowerPointSlides = 200; + private readonly ILogger _logger; + private readonly Dictionary> _extractorsByExtension; + + public TextExtractionService(ILogger logger) + { + _logger = logger; + _extractorsByExtension = new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + [".txt"] = (_, content) => ExtractTextFromTextFile(content), + [".csv"] = (_, content) => ExtractTextFromTextFile(content), + [".json"] = (_, content) => ExtractTextFromTextFile(content), + [".xml"] = (_, content) => ExtractTextFromTextFile(content), + [".pdf"] = ExtractTextFromPdfFile, + [".docx"] = ExtractTextFromWordDocx, + [".xls"] = ExtractTextFromExcelFile, + [".xlsx"] = ExtractTextFromExcelFile, + [".pptx"] = ExtractTextFromPowerPointFile + }; + } + + public Task ExtractTextAsync(string fileName, byte[] fileContent, string contentType) + { + if (fileContent == null || fileContent.Length == 0) + { + _logger.LogDebug("File content is empty for {FileName}", fileName); + return Task.FromResult(string.Empty); + } + + try + { + var normalizedContentType = contentType?.ToLowerInvariant() ?? string.Empty; + var extension = Path.GetExtension(fileName)?.ToLowerInvariant() ?? string.Empty; + + if (extension == ".doc") + { + _logger.LogDebug("Legacy .doc extraction is not supported for {FileName}", fileName); + return Task.FromResult(string.Empty); + } + + if (_extractorsByExtension.TryGetValue(extension, out var extractor)) + { + var rawText = extractor(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + + if (normalizedContentType.Contains("text/")) + { + var rawText = ExtractTextFromTextFile(fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + + if (normalizedContentType.Contains("pdf")) + { + var rawText = ExtractTextFromPdfFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + + if (normalizedContentType.Contains("word") || + normalizedContentType.Contains("msword") || + normalizedContentType.Contains("officedocument.wordprocessingml")) + { + var rawText = ExtractTextFromWordDocx(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + + if (normalizedContentType.Contains("excel") || normalizedContentType.Contains("spreadsheet")) + { + var rawText = ExtractTextFromExcelFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + + if (normalizedContentType.Contains("presentation") || + normalizedContentType.Contains("powerpoint")) + { + var rawText = ExtractTextFromPowerPointFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + + _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", + contentType, extension); + return Task.FromResult(string.Empty); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error extracting text from {FileName}", fileName); + return Task.FromResult(string.Empty); + } + } + + private string ExtractTextFromTextFile(byte[] fileContent) + { + try + { + var text = Encoding.UTF8.GetString(fileContent); + + if (text.Contains('\uFFFD')) + { + text = Encoding.ASCII.GetString(fileContent); + } + + if (text.Length > MaxExtractedTextLength) + { + text = text.Substring(0, MaxExtractedTextLength); + _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); + } + + _logger.LogDebug("Extracted {CharacterCount} characters from text-based content.", text.Length); + return text; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error decoding text file"); + return string.Empty; + } + } + + private string ExtractTextFromPdfFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var document = PdfDocument.Open(stream); + var builder = new StringBuilder(); + var processedPageCount = 0; + var pageTexts = document.GetPages() + .Select(page => page.Text) + .Where(pageText => !string.IsNullOrWhiteSpace(pageText)); + + foreach (var pageText in pageTexts) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + processedPageCount++; + if (TryAppendWithTrailingNewline(builder, pageText)) + { + break; + } + } + + _logger.LogDebug("Extracted PDF text from {ProcessedPageCount} pages for {FileName}", processedPageCount, fileName); + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PDF text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var document = new XWPFDocument(stream); + var builder = new StringBuilder(); + var processedParagraphCount = AppendDocxParagraphText(document, builder); + var processedTableRowCount = AppendDocxTableText(document, builder); + + _logger.LogDebug( + "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", + processedParagraphCount, + processedTableRowCount, + fileName); + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Word (.docx) text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private static int AppendDocxParagraphText(XWPFDocument document, StringBuilder builder) + { + var processedParagraphCount = 0; + var paragraphTexts = document.Paragraphs + .Take(MaxDocxParagraphs) + .Select(paragraph => paragraph.ParagraphText) + .Where(paragraphText => !string.IsNullOrWhiteSpace(paragraphText)); + + foreach (var paragraphText in paragraphTexts) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + processedParagraphCount++; + if (TryAppendWithTrailingNewline(builder, paragraphText)) + { + break; + } + } + + return processedParagraphCount; + } + + private static int AppendDocxTableText(XWPFDocument document, StringBuilder builder) + { + if (builder.Length >= MaxExtractedTextLength) + { + return 0; + } + + var processedTableRowCount = 0; + foreach (var table in document.Tables) + { + foreach (var row in table.Rows.Take(MaxDocxTableRows)) + { + if (builder.Length >= MaxExtractedTextLength) + { + return processedTableRowCount; + } + + var cellTexts = row.GetTableCells() + .Take(MaxDocxTableCellsPerRow) + .Select(cell => cell.GetText()) + .Where(cellText => !string.IsNullOrWhiteSpace(cellText)); + + var rowHadValue = false; + foreach (var cellText in cellTexts) + { + rowHadValue = true; + if (TryAppendWithTrailingNewline(builder, cellText)) + { + return processedTableRowCount + 1; + } + } + + if (rowHadValue) + { + processedTableRowCount++; + } + } + } + + return processedTableRowCount; + } + + private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var workbook = WorkbookFactory.Create(stream); + var builder = new StringBuilder(); + var sheetCount = Math.Min(workbook.NumberOfSheets, MaxExcelSheets); + var processedSheetCount = 0; + var processedRowCount = 0; + + for (var sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + var sheet = workbook.GetSheetAt(sheetIndex); + var (rowsProcessed, limitReached) = TryAppendExcelSheet(sheet, builder); + if (rowsProcessed > 0) + { + processedSheetCount++; + processedRowCount += rowsProcessed; + } + + if (limitReached) + { + break; + } + } + + _logger.LogDebug( + "Extracted Excel text from {ProcessedSheetCount} sheets and {ProcessedRowCount} rows for {FileName}", + processedSheetCount, + processedRowCount, + fileName); + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Excel text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private string ExtractTextFromPowerPointFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + var builder = new StringBuilder(); + var slideEntries = GetOrderedPowerPointSlideEntries(archive) + .Take(MaxPowerPointSlides); + var processedSlideCount = 0; + + foreach (var slideEntry in slideEntries) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + using var slideStream = slideEntry.Open(); + var slideText = ExtractPowerPointSlideText(slideStream); + if (string.IsNullOrWhiteSpace(slideText)) + { + continue; + } + + processedSlideCount++; + if (TryAppendWithTrailingNewline(builder, slideText)) + { + break; + } + } + + _logger.LogDebug("Extracted PowerPoint text from {ProcessedSlideCount} slides for {FileName}", processedSlideCount, fileName); + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PowerPoint (.pptx) text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive archive) + { + var slideEntriesByName = archive.Entries + .Where(entry => entry.FullName.StartsWith("ppt/slides/slide", StringComparison.OrdinalIgnoreCase) && + entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(entry => entry.FullName, StringComparer.OrdinalIgnoreCase); + + if (slideEntriesByName.Count == 0) + { + _logger.LogDebug("No slide entries found in PowerPoint archive."); + return Enumerable.Empty(); + } + + var orderedSlideNames = TryGetPowerPointSlideOrder(archive); + if (orderedSlideNames.Count == 0) + { + _logger.LogDebug("Using PowerPoint part-name order fallback for {SlideCount} slides.", slideEntriesByName.Count); + return slideEntriesByName.Values + .OrderBy(entry => GetPowerPointSlideNumber(entry.FullName)) + .ToList(); + } + + var orderedEntries = new List(slideEntriesByName.Count); + foreach (var slideName in orderedSlideNames) + { + if (slideEntriesByName.TryGetValue(slideName, out var slideEntry)) + { + orderedEntries.Add(slideEntry); + slideEntriesByName.Remove(slideName); + } + } + + if (slideEntriesByName.Count > 0) + { + orderedEntries.AddRange(slideEntriesByName.Values.OrderBy(entry => GetPowerPointSlideNumber(entry.FullName))); + } + + _logger.LogDebug("Resolved PowerPoint presentation order for {SlideCount} slides.", orderedEntries.Count); + return orderedEntries; + } + + private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) + { + if (sheet == null) + { + return (0, false); + } + + var processedRows = 0; + foreach (IRow row in sheet) + { + if (processedRows >= MaxExcelRowsPerSheet || builder.Length >= MaxExtractedTextLength) + { + break; + } + + var (rowHadValue, limitReached) = TryAppendExcelRow(row, builder); + if (rowHadValue) + { + processedRows++; + } + + if (limitReached) + { + return (processedRows, true); + } + } + + return (processedRows, builder.Length >= MaxExtractedTextLength); + } + + private static (bool RowHadValue, bool LimitReached) TryAppendExcelRow(IRow row, StringBuilder builder) + { + var rowHasValue = false; + foreach (var cell in row.Cells.Take(MaxExcelCellsPerRow)) + { + var value = GetCellText(cell); + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + string? separator = null; + if (rowHasValue) + { + separator = " | "; + } + + var limitReached = AppendWithLimit(builder, value, MaxExtractedTextLength, separator); + rowHasValue = true; + if (limitReached) + { + return (true, true); + } + } + + if (rowHasValue && + builder.Length + Environment.NewLine.Length <= MaxExtractedTextLength) + { + builder.Append(Environment.NewLine); + } + + return (rowHasValue, builder.Length >= MaxExtractedTextLength); + } + + private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? value) + { + var limitReached = AppendWithLimit(builder, value, MaxExtractedTextLength); + if (limitReached) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(value)) + { + AppendTrailingNewlineIfRoom(builder); + } + + return builder.Length >= MaxExtractedTextLength; + } + + private static string ExtractPowerPointSlideText(Stream slideStream) + { + var document = XDocument.Load(slideStream); + XNamespace drawingNamespace = "http://schemas.openxmlformats.org/drawingml/2006/main"; + var textRuns = document + .Descendants(drawingNamespace + "t") + .Select(node => node.Value?.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value)); + + return string.Join(Environment.NewLine, textRuns); + } + + private static int GetPowerPointSlideNumber(string entryName) + { + var fileName = Path.GetFileNameWithoutExtension(entryName); + if (string.IsNullOrWhiteSpace(fileName)) + { + return int.MaxValue; + } + + var slideNumberText = fileName.Substring("slide".Length); + return int.TryParse(slideNumberText, out var slideNumber) + ? slideNumber + : int.MaxValue; + } + + private List TryGetPowerPointSlideOrder(ZipArchive archive) + { + try + { + var presentationEntry = archive.GetEntry("ppt/presentation.xml"); + var relationshipsEntry = archive.GetEntry("ppt/_rels/presentation.xml.rels"); + if (presentationEntry == null || relationshipsEntry == null) + { + return new List(); + } + + using var presentationStream = presentationEntry.Open(); + using var relationshipsStream = relationshipsEntry.Open(); + var presentationDocument = XDocument.Load(presentationStream); + var relationshipsDocument = XDocument.Load(relationshipsStream); + + XNamespace presentationNamespace = "http://schemas.openxmlformats.org/presentationml/2006/main"; + XNamespace officeDocumentRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + XNamespace packageRelationshipsNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; + + var slideTargetsByRelationshipId = (relationshipsDocument + .Root? + .Elements(packageRelationshipsNamespace + "Relationship") + .Where(element => string.Equals( + element.Attribute("Type")?.Value, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide", + StringComparison.OrdinalIgnoreCase)) + .Select(element => new + { + Id = element.Attribute("Id")?.Value, + Target = NormalizePowerPointSlideTarget(element.Attribute("Target")?.Value) + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Id) && !string.IsNullOrWhiteSpace(item.Target)) + .ToDictionary(item => item.Id!, item => item.Target!, StringComparer.OrdinalIgnoreCase)) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + return presentationDocument + .Descendants(presentationNamespace + "sldId") + .Select(element => element.Attribute(officeDocumentRelationshipsNamespace + "id")?.Value) + .Where(relationshipId => !string.IsNullOrWhiteSpace(relationshipId)) + .Select(relationshipId => slideTargetsByRelationshipId.GetValueOrDefault(relationshipId!)) + .Where(target => !string.IsNullOrWhiteSpace(target)) + .Cast() + .ToList(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Falling back to part-name slide order for PowerPoint extraction."); + return new List(); + } + } + + private static string? NormalizePowerPointSlideTarget(string? target) + { + if (string.IsNullOrWhiteSpace(target)) + { + return null; + } + + var normalizedTarget = target.Replace('\\', '/').TrimStart('/'); + if (normalizedTarget.StartsWith("ppt/", StringComparison.OrdinalIgnoreCase)) + { + return normalizedTarget; + } + + if (normalizedTarget.StartsWith("slides/", StringComparison.OrdinalIgnoreCase)) + { + return $"ppt/{normalizedTarget}"; + } + + if (normalizedTarget.StartsWith("../", StringComparison.OrdinalIgnoreCase)) + { + normalizedTarget = normalizedTarget.Substring(3); + } + + return $"ppt/{normalizedTarget}"; + } + + private static void AppendTrailingNewlineIfRoom(StringBuilder builder) + { + if (builder.Length > 0 && + builder.Length + Environment.NewLine.Length <= MaxExtractedTextLength) + { + builder.Append(Environment.NewLine); + } + } + + private static bool AppendWithLimit(StringBuilder builder, string? value, int maxLength, string? separator = null) + { + if (string.IsNullOrWhiteSpace(value)) + { + return builder.Length >= maxLength; + } + + if (builder.Length >= maxLength) + { + return true; + } + + var remaining = maxLength - builder.Length; + if (remaining <= 0) + { + return true; + } + + if (!string.IsNullOrEmpty(separator) && builder.Length > 0) + { + if (separator.Length >= remaining) + { + builder.Append(separator.AsSpan(0, remaining)); + return true; + } + + builder.Append(separator); + remaining -= separator.Length; + } + + if (value.Length >= remaining) + { + builder.Append(value.AsSpan(0, remaining)); + return true; + } + + builder.Append(value); + return false; + } + + private static string GetCellText(NPOI.SS.UserModel.ICell cell) + { + if (cell == null) + { + return string.Empty; + } + + return (cell.CellType switch + { + CellType.String => cell.StringCellValue ?? string.Empty, + CellType.Numeric => DateUtil.IsCellDateFormatted(cell) + ? cell.DateCellValue.ToString() + : cell.NumericCellValue.ToString(), + CellType.Boolean => cell.BooleanCellValue ? "true" : "false", + CellType.Formula => cell.ToString(), + CellType.Blank => string.Empty, + _ => cell.ToString() ?? string.Empty + }) ?? string.Empty; + } + + private string NormalizeAndLimitText(string text, string fileName) + { + var normalized = NormalizeExtractedText(text); + normalized = RemoveLeadingFileNameArtifact(normalized, fileName); + + if (normalized.Length > MaxExtractedTextLength) + { + normalized = normalized.Substring(0, MaxExtractedTextLength); + _logger.LogDebug("Truncated extracted content to {MaxLength} characters", MaxExtractedTextLength); + } + + return normalized; + } + + private static string NormalizeExtractedText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + var normalized = text + .Replace('\0', ' ') + .Replace("\r\n", "\n") + .Replace('\r', '\n'); + + normalized = LowerToUpperWordBoundaryRegex().Replace(normalized, " "); + normalized = PunctuationToWordBoundaryRegex().Replace(normalized, " "); + normalized = ColonDashSpacingRegex().Replace(normalized, ": - "); + normalized = HyphenSpacingRegex().Replace(normalized, " - "); + normalized = KeywordBoundaryRegex().Replace(normalized, " "); + normalized = MultipleSpacesRegex().Replace(normalized, " "); + normalized = NewlineWhitespaceRegex().Replace(normalized, "\n"); + normalized = MultipleNewlinesRegex().Replace(normalized, "\n"); + + return normalized.Trim(); + } + + private static string RemoveLeadingFileNameArtifact(string text, string fileName) + { + if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(fileName)) + { + return text; + } + + var rawStem = Path.GetFileNameWithoutExtension(fileName)?.Trim(); + if (string.IsNullOrWhiteSpace(rawStem)) + { + return text; + } + + var decodedStem = Uri.UnescapeDataString(rawStem); + foreach (var candidate in new[] { rawStem, decodedStem }) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + if (text.StartsWith(candidate, StringComparison.OrdinalIgnoreCase)) + { + var stripped = text.Substring(candidate.Length).TrimStart(' ', '-', ':', '.', '\t'); + if (!string.IsNullOrWhiteSpace(stripped)) + { + return stripped; + } + } + } + + return text; + } + + [GeneratedRegex(@"(?<=[a-z])(?=[A-Z])")] + private static partial Regex LowerToUpperWordBoundaryRegex(); + + [GeneratedRegex(@"(?<=[\.\,\:\;\)])(?=[A-Za-z0-9])")] + private static partial Regex PunctuationToWordBoundaryRegex(); + + [GeneratedRegex(@":-")] + private static partial Regex ColonDashSpacingRegex(); + + [GeneratedRegex(@"(?<=\S)- (?=[A-Za-z])")] + private static partial Regex HyphenSpacingRegex(); + + [GeneratedRegex(@"(?<=[a-z])(?=(project|funding|budget|community|summary|notes|details|planning|outcomes|background|services)\b)", RegexOptions.IgnoreCase)] + private static partial Regex KeywordBoundaryRegex(); + + [GeneratedRegex(@"[ \t]+")] + private static partial Regex MultipleSpacesRegex(); + + [GeneratedRegex(@"\n\s*")] + private static partial Regex NewlineWhitespaceRegex(); + + [GeneratedRegex(@"\n{2,}")] + private static partial Regex MultipleNewlinesRegex(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs deleted file mode 100644 index 418c31ebcf..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ /dev/null @@ -1,833 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using Volo.Abp.DependencyInjection; - -namespace Unity.GrantManager.AI -{ - public class OpenAIService : IAIService, ITransientDependency - { - private readonly HttpClient _httpClient; - private readonly IConfiguration _configuration; - private readonly ILogger _logger; - private readonly ITextExtractionService _textExtractionService; - private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; - private const string AttachmentSummaryPromptType = "AttachmentSummary"; - private const string ScoresheetAllPromptType = "ScoresheetAll"; - private const string ScoresheetSectionPromptType = "ScoresheetSection"; - private const string NoSummaryGeneratedMessage = "No summary generated."; - private const string ServiceNotConfiguredMessage = "AI analysis not available - service not configured."; - private const string ServiceTemporarilyUnavailableMessage = "AI analysis failed - service temporarily unavailable."; - private const string SummaryFailedRetryMessage = "AI analysis failed - please try again later."; - - private string? ApiKey => _configuration["Azure:OpenAI:ApiKey"]; - private string? ApiUrl => _configuration["Azure:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; - private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; - - // Optional local debugging sink for prompt payload logs to a local file. - // Not intended for deployed/shared environments. - private bool IsPromptFileLoggingEnabled => _configuration.GetValue("Azure:Logging:EnablePromptFileLog") ?? false; - private const string PromptLogDirectoryName = "logs"; - private static readonly string PromptLogFileName = $"ai-prompts-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Environment.ProcessId}.log"; - - private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; - - public OpenAIService( - HttpClient httpClient, - IConfiguration configuration, - ILogger logger, - ITextExtractionService textExtractionService) - { - _httpClient = httpClient; - _configuration = configuration; - _logger = logger; - _textExtractionService = textExtractionService; - } - - public Task IsAvailableAsync() - { - if (string.IsNullOrEmpty(ApiKey)) - { - _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); - return Task.FromResult(false); - } - - return Task.FromResult(true); - } - - public async Task GenerateCompletionAsync(AICompletionRequest request) - { - var content = await GenerateSummaryAsync( - request?.UserPrompt ?? string.Empty, - request?.SystemPrompt, - request?.MaxTokens ?? 150); - return new AICompletionResponse { Content = content }; - } - - public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) - { - var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); - var schemaJson = JsonSerializer.Serialize(request.Schema, JsonLogOptions); - - var attachmentsPayload = request.Attachments - .Select(a => new - { - name = string.IsNullOrWhiteSpace(a.Name) ? "attachment" : a.Name.Trim(), - summary = string.IsNullOrWhiteSpace(a.Summary) ? string.Empty : a.Summary.Trim() - }) - .Cast(); - - var analysisContent = AnalysisPrompts.BuildUserPrompt( - schemaJson, - dataJson, - JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions), - request.Rubric ?? string.Empty); - - var systemPrompt = AnalysisPrompts.SystemPrompt; - await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); - var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, raw); - return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(raw)); - } - - public async Task GenerateSummaryAsync(string content, string? prompt = null, int maxTokens = 150) - { - if (string.IsNullOrEmpty(ApiKey)) - { - _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); - return ServiceNotConfiguredMessage; - } - - _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); - - try - { - var systemPrompt = prompt ?? "You are a professional grant analyst for the BC Government."; - var userPrompt = content ?? string.Empty; - - var requestBody = new - { - messages = new[] - { - new { role = "system", content = systemPrompt }, - new { role = "user", content = userPrompt } - }, - max_tokens = maxTokens, - temperature = 0.3 - }; - - var json = JsonSerializer.Serialize(requestBody); - var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("Authorization", ApiKey); - - var response = await _httpClient.PostAsync(ApiUrl, httpContent); - var responseContent = await response.Content.ReadAsStringAsync(); - - _logger.LogDebug( - "OpenAI chat completions response received. StatusCode: {StatusCode}, ResponseLength: {ResponseLength}", - response.StatusCode, - responseContent?.Length ?? 0); - - if (!response.IsSuccessStatusCode) - { - _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); - return ServiceTemporarilyUnavailableMessage; - } - - if (string.IsNullOrWhiteSpace(responseContent)) - { - return NoSummaryGeneratedMessage; - } - - using var jsonDoc = JsonDocument.Parse(responseContent); - var choices = jsonDoc.RootElement.GetProperty("choices"); - if (choices.GetArrayLength() > 0) - { - var message = choices[0].GetProperty("message"); - return message.GetProperty("content").GetString() ?? NoSummaryGeneratedMessage; - } - - return NoSummaryGeneratedMessage; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating AI summary"); - return SummaryFailedRetryMessage; - } - } - - public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] fileContent, string contentType) - { - try - { - var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); - - var prompt = $@"{AttachmentPrompts.SystemPrompt} - -{AttachmentPrompts.OutputSection} - -{AttachmentPrompts.RulesSection}"; - - var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; - if (attachmentText != null) - { - _logger.LogDebug("Extracted {TextLength} characters from {FileName}", extractedText.Length, fileName); - } - else - { - _logger.LogDebug("No text extracted from {FileName}, analyzing metadata only", fileName); - } - - var attachmentPayload = new - { - name = fileName, - contentType, - sizeBytes = fileContent.Length, - text = attachmentText - }; - var contentToAnalyze = AttachmentPrompts.BuildUserPrompt( - JsonSerializer.Serialize(attachmentPayload, JsonLogOptions)); - - await LogPromptInputAsync(AttachmentSummaryPromptType, prompt, contentToAnalyze); - var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - await LogPromptOutputAsync(AttachmentSummaryPromptType, modelOutput); - return modelOutput; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); - return $"AI analysis not available for this attachment ({fileName})."; - } - } - - public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) - { - var summary = await GenerateAttachmentSummaryAsync( - request?.FileName ?? string.Empty, - request?.FileContent ?? Array.Empty(), - request?.ContentType ?? "application/octet-stream"); - return new AttachmentSummaryResponse { Summary = summary }; - } - - public async Task AnalyzeApplicationAsync(string applicationContent, List attachmentSummaries, string rubric, string? formFieldConfiguration = null) - { - if (string.IsNullOrEmpty(ApiKey)) - { - _logger.LogWarning("{Message}", MissingApiKeyMessage); - return ServiceNotConfiguredMessage; - } - - try - { - object schemaPayload = new { }; - if (!string.IsNullOrWhiteSpace(formFieldConfiguration)) - { - try - { - using var schemaDoc = JsonDocument.Parse(formFieldConfiguration); - schemaPayload = schemaDoc.RootElement.Clone(); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "Invalid form field configuration JSON. Using empty schema payload."); - } - } - - var dataPayload = new - { - applicationContent - }; - - var attachmentsPayload = attachmentSummaries?.Count > 0 - ? attachmentSummaries - .Select((summary, index) => new - { - name = $"Attachment {index + 1}", - summary = summary - }) - .Cast() - : Enumerable.Empty(); - - var analysisContent = AnalysisPrompts.BuildUserPrompt( - JsonSerializer.Serialize(schemaPayload, JsonLogOptions), - JsonSerializer.Serialize(dataPayload, JsonLogOptions), - JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions), - rubric); - - var systemPrompt = AnalysisPrompts.SystemPrompt; - - await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); - var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, rawAnalysis); - - // Post-process the AI response to add unique IDs to errors and warnings - return AddIdsToAnalysisItems(rawAnalysis); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error analyzing application"); - return SummaryFailedRetryMessage; - } - } - - private string AddIdsToAnalysisItems(string analysisJson) - { - try - { - using var jsonDoc = JsonDocument.Parse(analysisJson); - using var memoryStream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - - foreach (var property in jsonDoc.RootElement.EnumerateObject()) - { - var outputPropertyName = property.Name; - - if (outputPropertyName == AIJsonKeys.Errors || outputPropertyName == AIJsonKeys.Warnings) - { - writer.WritePropertyName(outputPropertyName); - writer.WriteStartArray(); - - foreach (var item in property.Value.EnumerateArray()) - { - writer.WriteStartObject(); - - // Add unique ID first - writer.WriteString("id", Guid.NewGuid().ToString()); - - // Copy existing properties - foreach (var itemProperty in item.EnumerateObject()) - { - itemProperty.WriteTo(writer); - } - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - } - else - { - if (outputPropertyName != property.Name) - { - writer.WritePropertyName(outputPropertyName); - property.Value.WriteTo(writer); - continue; - } - - property.WriteTo(writer); - } - } - - // Add dismissed array if not present. - if (!jsonDoc.RootElement.TryGetProperty(AIJsonKeys.Dismissed, out _)) - { - writer.WritePropertyName(AIJsonKeys.Dismissed); - writer.WriteStartArray(); - writer.WriteEndArray(); - } - - writer.WriteEndObject(); - } - - return Encoding.UTF8.GetString(memoryStream.ToArray()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding IDs to analysis items, returning original JSON"); - return analysisJson; // Return original if processing fails - } - } - - public async Task GenerateScoresheetAnswersAsync(string applicationContent, List attachmentSummaries, string scoresheetQuestions) - { - if (string.IsNullOrEmpty(ApiKey)) - { - _logger.LogWarning("{Message}", MissingApiKeyMessage); - return "{}"; - } - - try - { - var attachmentSummariesText = attachmentSummaries?.Count > 0 - ? string.Join("\n- ", attachmentSummaries.Select((s, i) => $"Attachment {i + 1}: {s}")) - : "No attachments provided."; - - var analysisContent = $@"APPLICATION CONTENT: -{applicationContent} - -ATTACHMENT SUMMARIES: -- {attachmentSummariesText} - -SCORESHEET QUESTIONS: -{scoresheetQuestions} - -Please analyze this grant application and provide appropriate answers for each scoresheet question. - -For numeric questions, provide a numeric value within the specified range. -For yes/no questions, provide either 'Yes' or 'No'. -For text questions, provide a concise, relevant response. -For select list questions, choose the most appropriate option from the provided choices. -For text area questions, provide a detailed but concise response. - -Base your answers on the application content and attachment summaries provided. Be objective and fair in your assessment. - -Return your response as a JSON object where each key is the question ID and the value is the appropriate answer: -{{ - ""question-id-1"": ""answer-value-1"", - ""question-id-2"": ""answer-value-2"" -}} -Do not return any markdown formatting, just the JSON by itself"; - - var systemPrompt = @"You are an expert grant application reviewer for the BC Government. -Analyze the provided application and generate appropriate answers for the scoresheet questions based on the application content. -Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. -Respond only with valid JSON in the exact format requested."; - - await LogPromptInputAsync(ScoresheetAllPromptType, systemPrompt, analysisContent); - var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync(ScoresheetAllPromptType, modelOutput); - return modelOutput; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating scoresheet answers"); - return "{}"; - } - } - - public async Task GenerateScoresheetSectionAnswersAsync(string applicationContent, List attachmentSummaries, string sectionJson, string sectionName) - { - if (string.IsNullOrEmpty(ApiKey)) - { - _logger.LogWarning("{Message}", MissingApiKeyMessage); - return "{}"; - } - - try - { - var attachmentSummariesText = attachmentSummaries?.Count > 0 - ? string.Join("\n- ", attachmentSummaries.Select((s, i) => $"Attachment {i + 1}: {s}")) - : "No attachments provided."; - - object sectionQuestionsPayload = sectionJson; - if (!string.IsNullOrWhiteSpace(sectionJson)) - { - try - { - using var sectionDoc = JsonDocument.Parse(sectionJson); - sectionQuestionsPayload = sectionDoc.RootElement.Clone(); - } - catch (JsonException) - { - // Keep raw string payload when JSON parsing fails. - } - } - - var sectionPayload = new - { - name = sectionName, - questions = sectionQuestionsPayload - }; - var sectionPayloadJson = JsonSerializer.Serialize(sectionPayload, JsonLogOptions); - var responseTemplate = BuildScoresheetSectionResponseTemplate(sectionPayloadJson); - - var analysisContent = ScoresheetPrompts.BuildSectionUserPrompt( - applicationContent, - attachmentSummariesText, - sectionPayloadJson, - responseTemplate); - - var systemPrompt = ScoresheetPrompts.SectionSystemPrompt; - - await LogPromptInputAsync(ScoresheetSectionPromptType, systemPrompt, analysisContent); - var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync(ScoresheetSectionPromptType, modelOutput); - return modelOutput; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", sectionName); - return "{}"; - } - } - - public async Task GenerateScoresheetSectionAnswersAsync(ScoresheetSectionRequest request) - { - var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); - var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); - - var attachmentSummaries = request.Attachments - .Select(a => $"{a.Name}: {a.Summary}") - .ToList(); - - var raw = await GenerateScoresheetSectionAnswersAsync( - dataJson, - attachmentSummaries, - sectionJson, - request.SectionName); - return ParseScoresheetSectionResponse(raw); - } - - private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(string raw) - { - var response = new ApplicationAnalysisResponse(); - - if (!TryParseJsonObjectFromResponse(raw, out var root)) - { - return response; - } - - if (TryGetStringProperty(root, AIJsonKeys.Rating, out var rating)) - { - response.Rating = rating; - } - - if (root.TryGetProperty("errors", out var errors) && errors.ValueKind == JsonValueKind.Array) - { - response.Errors = ParseFindings(errors); - } - - if (root.TryGetProperty("warnings", out var warnings) && warnings.ValueKind == JsonValueKind.Array) - { - response.Warnings = ParseFindings(warnings); - } - - if (root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) && summaries.ValueKind == JsonValueKind.Array) - { - response.Summaries = ParseFindings(summaries); - } - - if (root.TryGetProperty(AIJsonKeys.Dismissed, out var dismissed) && dismissed.ValueKind == JsonValueKind.Array) - { - response.Dismissed = dismissed - .EnumerateArray() - .Select(GetStringValueOrNull) - .Where(item => !string.IsNullOrWhiteSpace(item)) - .Cast() - .ToList(); - } - - return response; - } - - private static bool TryGetStringProperty(JsonElement root, string propertyName, out string? value) - { - value = null; - if (!root.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) - { - return false; - } - - value = property.GetString(); - return !string.IsNullOrWhiteSpace(value); - } - - private static string? GetStringValueOrNull(JsonElement element) - { - if (element.ValueKind == JsonValueKind.String) - { - return element.GetString(); - } - - return null; - } - - private static List ParseFindings(JsonElement array) - { - var findings = new List(); - foreach (var item in array.EnumerateArray()) - { - if (item.ValueKind != JsonValueKind.Object) - { - continue; - } - - var id = item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String - ? idProp.GetString() - : null; - string? title = null; - if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) - { - title = titleProp.GetString(); - } - else if (item.TryGetProperty("category", out var legacyTitleProp) && - legacyTitleProp.ValueKind == JsonValueKind.String) - { - title = legacyTitleProp.GetString(); - } - - string? detail = null; - if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) - { - detail = detailProp.GetString(); - } - else if (item.TryGetProperty("message", out var legacyDetailProp) && - legacyDetailProp.ValueKind == JsonValueKind.String) - { - detail = legacyDetailProp.GetString(); - } - - findings.Add(new ApplicationAnalysisFinding - { - Id = id, - Title = title, - Detail = detail - }); - } - - return findings; - } - - private static ScoresheetSectionResponse ParseScoresheetSectionResponse(string raw) - { - var response = new ScoresheetSectionResponse(); - if (!TryParseJsonObjectFromResponse(raw, out var root)) - { - return response; - } - - foreach (var property in root.EnumerateObject()) - { - if (property.Value.ValueKind != JsonValueKind.Object) - { - continue; - } - - var answer = property.Value.TryGetProperty("answer", out var answerProp) - ? answerProp.Clone() - : default; - var rationale = property.Value.TryGetProperty("rationale", out var rationaleProp) && - rationaleProp.ValueKind == JsonValueKind.String - ? rationaleProp.GetString() ?? string.Empty - : string.Empty; - var confidence = property.Value.TryGetProperty("confidence", out var confidenceProp) && - confidenceProp.ValueKind == JsonValueKind.Number && - confidenceProp.TryGetInt32(out var parsedConfidence) - ? NormalizeConfidence(parsedConfidence) - : 0; - - response.Answers[property.Name] = new ScoresheetSectionAnswer - { - Answer = answer, - Rationale = rationale, - Confidence = confidence - }; - } - - return response; - } - - private static int NormalizeConfidence(int confidence) - { - var clamped = Math.Clamp(confidence, 0, 100); - var rounded = (int)Math.Round(clamped / 5.0, MidpointRounding.AwayFromZero) * 5; - return Math.Clamp(rounded, 0, 100); - } - - private static string BuildScoresheetSectionResponseTemplate(string sectionPayloadJson) - { - try - { - using var doc = JsonDocument.Parse(sectionPayloadJson); - if (!doc.RootElement.TryGetProperty("questions", out var questions) || questions.ValueKind != JsonValueKind.Array) - { - return ScoresheetPrompts.SectionOutputTemplate; - } - - var template = new Dictionary(); - foreach (var question in questions.EnumerateArray()) - { - if (!question.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) - { - continue; - } - - var questionId = idProp.GetString(); - if (string.IsNullOrWhiteSpace(questionId)) - { - continue; - } - - template[questionId] = new - { - answer = string.Empty, - rationale = string.Empty, - confidence = 0 - }; - } - - if (template.Count == 0) - { - return ScoresheetPrompts.SectionOutputTemplate; - } - - return JsonSerializer.Serialize(template, JsonLogOptions); - } - catch (JsonException) - { - return ScoresheetPrompts.SectionOutputTemplate; - } - } - - private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) - { - var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogInformation("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); - await WritePromptLogFileAsync(promptType, "INPUT", formattedInput); - } - - private async Task LogPromptOutputAsync(string promptType, string output) - { - var formattedOutput = FormatPromptOutputForLog(output); - _logger.LogInformation("AI {PromptType} model output payload: {ModelOutput}", promptType, formattedOutput); - await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); - } - - private async Task WritePromptLogFileAsync(string promptType, string payloadType, string payload) - { - if (!CanWritePromptFileLog()) - { - return; - } - - try - { - var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); - var logDirectory = Path.Combine(AppContext.BaseDirectory, PromptLogDirectoryName); - Directory.CreateDirectory(logDirectory); - - var logPath = Path.Combine(logDirectory, PromptLogFileName); - var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; - await File.AppendAllTextAsync(logPath, entry); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to write AI prompt log file."); - } - } - - private bool CanWritePromptFileLog() - { - return IsPromptFileLoggingEnabled; - } - - private static string FormatPromptInputForLog(string? systemPrompt, string userPrompt) - { - var normalizedSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt) ? string.Empty : systemPrompt.Trim(); - var normalizedUserPrompt = string.IsNullOrWhiteSpace(userPrompt) ? string.Empty : userPrompt.Trim(); - return $"SYSTEM_PROMPT\n{normalizedSystemPrompt}\n\nUSER_PROMPT\n{normalizedUserPrompt}"; - } - - private static string FormatPromptOutputForLog(string output) - { - if (string.IsNullOrWhiteSpace(output)) - { - return string.Empty; - } - - if (TryParseJsonObjectFromResponse(output, out var jsonObject)) - { - return JsonSerializer.Serialize(jsonObject, JsonLogOptions); - } - - return output.Trim(); - } - - private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) - { - objectElement = default; - var cleaned = CleanJsonResponse(response); - if (string.IsNullOrWhiteSpace(cleaned)) - { - return false; - } - - try - { - using var doc = JsonDocument.Parse(cleaned); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return false; - } - - objectElement = doc.RootElement.Clone(); - return true; - } - catch (JsonException) - { - return false; - } - } - - private static string CleanJsonResponse(string response) - { - if (string.IsNullOrWhiteSpace(response)) - { - return string.Empty; - } - - var cleaned = response.Trim(); - - if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) - { - var startIndex = cleaned.IndexOf('\n'); - if (startIndex >= 0) - { - // Multi-line fenced code block: remove everything up to and including the first newline. - cleaned = cleaned[(startIndex + 1)..]; - } - else - { - // Single-line fenced JSON, e.g. ```json { ... } ``` or ```{ ... } ```. - // Strip everything before the first likely JSON payload token. - var jsonStart = FindFirstJsonTokenIndex(cleaned); - - if (jsonStart > 0) - { - cleaned = cleaned[jsonStart..]; - } - } - } - - if (cleaned.EndsWith("```", StringComparison.Ordinal)) - { - var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); - if (lastIndex > 0) - { - cleaned = cleaned[..lastIndex]; - } - } - - return cleaned.Trim(); - } - - private static int FindFirstJsonTokenIndex(string value) - { - var objectStart = value.IndexOf('{'); - var arrayStart = value.IndexOf('['); - - if (objectStart >= 0 && arrayStart >= 0) - { - return Math.Min(objectStart, arrayStart); - } - - if (objectStart >= 0) - { - return objectStart; - } - - return arrayStart; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/ApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/ApplicationAnalysisService.cs new file mode 100644 index 0000000000..a06863fc8d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/ApplicationAnalysisService.cs @@ -0,0 +1,201 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.GrantManager.AI.Models; +using Unity.GrantManager.AI.Prompts; +using Unity.GrantManager.AI.Requests; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI.Operations +{ + public class ApplicationAnalysisService( + IApplicationRepository applicationRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IAIService aiService, + ILogger logger) : IApplicationAnalysisService, ITransientDependency + { + private readonly JsonSerializerOptions _jsonOptionsIndented = new() + { + WriteIndented = true + }; + + private const string ComponentsKey = "components"; + private static readonly HashSet ExcludedSchemaKeys = new(StringComparer.OrdinalIgnoreCase) + { + "applicantAgent" + }; + + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null) + { + var application = await applicationRepository.GetAsync(applicationId); + var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + + var attachmentSummaries = attachments + .Where(a => !string.IsNullOrWhiteSpace(a.AISummary)) + .Select(a => new AIAttachmentItem + { + Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), + Summary = a.AISummary!.Trim() + }) + .ToList(); + + object formFieldConfiguration = new { message = "Form configuration not available." }; + if (formSubmission?.ApplicationFormVersionId != null) + { + formFieldConfiguration = await ExtractFormFieldConfigurationAsync(formSubmission.ApplicationFormVersionId.Value); + } + + var analysis = await aiService.GenerateApplicationAnalysisAsync(new ApplicationAnalysisRequest + { + Schema = JsonSerializer.SerializeToElement(formFieldConfiguration), + Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger), + Attachments = attachmentSummaries, + PromptVersion = promptVersion, + }); + + var analysisJson = JsonSerializer.Serialize(analysis, _jsonOptionsIndented); + application.AIAnalysis = analysisJson; + await applicationRepository.UpdateAsync(application); + return analysisJson; + } + + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to load form schema for prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + + private async Task ExtractFormFieldConfigurationAsync(Guid formVersionId) + { + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId); + if (formVersion == null || string.IsNullOrEmpty(formVersion.FormSchema)) + { + return new { message = "Form configuration not available." }; + } + + var schema = JObject.Parse(formVersion.FormSchema); + var components = schema[ComponentsKey] as JArray; + if (components == null || components.Count == 0) + { + return new { message = "No form fields configured." }; + } + + var requiredFields = new List(); + var optionalFields = new List(); + ExtractFieldRequirements(components, requiredFields, optionalFields, string.Empty); + + return new + { + required_fields = requiredFields, + optional_fields = optionalFields + }; + } + catch (Exception ex) + { + logger.LogError(ex, "Error extracting form field configuration for form version {FormVersionId}", formVersionId); + return new { message = "Form configuration could not be extracted." }; + } + } + + private static void ExtractFieldRequirements(JArray components, List requiredFields, List optionalFields, string currentPath) + { + foreach (var component in components.OfType()) + { + var key = component["key"]?.ToString(); + var label = component["label"]?.ToString(); + var type = component["type"]?.ToString(); + var skipTypes = new HashSet { "button", "simplebuttonadvanced", "html", "htmlelement", "content", "simpleseparator" }; + + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(type) || skipTypes.Contains(type) || ExcludedSchemaKeys.Contains(key)) + { + ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, currentPath); + continue; + } + + var displayName = !string.IsNullOrEmpty(label) ? $"{label} ({key})" : key; + var fullPath = string.IsNullOrEmpty(currentPath) ? displayName : $"{currentPath} > {displayName}"; + var validate = component["validate"] as JObject; + var isRequired = validate?["required"]?.Value() ?? false; + + if (component["input"]?.Value() == true) + { + if (isRequired) requiredFields.Add(fullPath); + else optionalFields.Add(fullPath); + } + + ProcessNestedFieldRequirements(component, type, requiredFields, optionalFields, fullPath); + } + } + + private static void ProcessNestedFieldRequirements(JObject component, string? type, List requiredFields, List optionalFields, string currentPath) + { + switch (type) + { + case "panel": + case "simplepanel": + case "fieldset": + case "well": + case "container": + case "datagrid": + case "table": + if (component[ComponentsKey] is JArray nestedComponents) + { + ExtractFieldRequirements(nestedComponents, requiredFields, optionalFields, currentPath); + } + break; + case "columns": + case "simplecols2": + case "simplecols3": + case "simplecols4": + if (component["columns"] is JArray columns) + { + foreach (var column in columns.OfType()) + { + if (column[ComponentsKey] is JArray columnComponents) + { + ExtractFieldRequirements(columnComponents, requiredFields, optionalFields, currentPath); + } + } + } + break; + case "tabs": + case "simpletabs": + if (component[ComponentsKey] is JArray tabs) + { + foreach (var tab in tabs.OfType()) + { + if (tab[ComponentsKey] is JArray tabComponents) + { + ExtractFieldRequirements(tabComponents, requiredFields, optionalFields, currentPath); + } + } + } + break; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/ApplicationScoringService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/ApplicationScoringService.cs new file mode 100644 index 0000000000..b73b76fe27 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/ApplicationScoringService.cs @@ -0,0 +1,196 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.Flex.Domain.Scoresheets; +using Unity.GrantManager.AI.Models; +using Unity.GrantManager.AI.Prompts; +using Unity.GrantManager.AI.Requests; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI.Operations +{ + public class ApplicationScoringService( + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IScoresheetRepository scoresheetRepository, + IAIService aiService, + ILogger logger) : IApplicationScoringService, ITransientDependency + { + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private readonly JsonSerializerOptions _jsonOptionsIndented = new() + { + WriteIndented = true + }; + + public async Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null) + { + var application = await applicationRepository.GetAsync(applicationId); + var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); + if (applicationForm.ScoresheetId == null) + { + return "{}"; + } + + var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); + if (scoresheet == null) + { + return "{}"; + } + + var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId); + var attachmentSummaries = attachments + .Where(a => !string.IsNullOrEmpty(a.AISummary)) + .Select(a => new AIAttachmentItem + { + Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), + Summary = a.AISummary!.Trim() + }) + .ToList(); + + var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); + var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission, formSchema, logger); + + var allSectionResults = new Dictionary(); + foreach (var section in scoresheet.Sections.OrderBy(s => s.Order)) + { + try + { + var sectionQuestionsData = new List(); + foreach (var field in section.Fields.OrderBy(f => f.Order)) + { + var options = ExtractSelectListOptions(field); + sectionQuestionsData.Add(new + { + id = field.Id.ToString(), + question = field.Label, + description = field.Description, + type = field.Type.ToString(), + options, + allowed_answers = ExtractSelectListOptionNumbers(options) + }); + } + + var applicationScoringRequest = new ApplicationScoringRequest + { + Data = promptData, + Attachments = attachmentSummaries, + SectionName = section.Name, + SectionSchema = JsonSerializer.SerializeToElement(sectionQuestionsData, _jsonOptions), + PromptVersion = promptVersion, + }; + var applicationScoringResponse = await aiService.GenerateApplicationScoringAsync(applicationScoringRequest); + + if (applicationScoringResponse.Answers.Count > 0) + { + var sectionJson = JsonSerializer.Serialize(applicationScoringResponse.Answers, _jsonOptions); + using var sectionDoc = JsonDocument.Parse(sectionJson); + foreach (var property in sectionDoc.RootElement.EnumerateObject()) + { + allSectionResults[property.Name] = property.Value.Clone(); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing AI application scoring section {SectionName} for application {ApplicationId}", section.Name, applicationId); + } + } + + var combinedResults = JsonSerializer.Serialize(allSectionResults, _jsonOptionsIndented); + var validatedJson = ValidateApplicationScoringJson(combinedResults); + application.AIScoresheetAnswers = validatedJson; + await applicationRepository.UpdateAsync(application); + return validatedJson; + } + + private async Task GetFormSchemaAsync(Guid? formVersionId) + { + if (formVersionId == null) + { + return null; + } + + try + { + var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Unable to load form schema for application scoring prompt data generation for form version {FormVersionId}.", formVersionId); + return null; + } + } + + private static string ValidateApplicationScoringJson(string scoresheetAnswers) + { + try + { + if (!string.IsNullOrWhiteSpace(scoresheetAnswers)) + { + using var _ = JsonDocument.Parse(scoresheetAnswers); + return scoresheetAnswers; + } + } + catch (JsonException) + { + // Fall back to empty object for invalid JSON. + } + + return "{}"; + } + + private static object[]? ExtractSelectListOptions(Question field) + { + if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition)) + return null; + + try + { + var definition = JsonSerializer.Deserialize(field.Definition); + if (definition?.Options != null && definition.Options.Count > 0) + { + return definition.Options + .Select((option, index) => + (object)new + { + number = index + 1, + value = option.Value + }) + .ToArray(); + } + } + catch (JsonException) + { + // Ignore malformed definition and return null options. + } + + return null; + } + + private static string[]? ExtractSelectListOptionNumbers(object[]? options) + { + if (options == null || options.Length == 0) + { + return null; + } + + return options + .Select((_, index) => (index + 1).ToString()) + .ToArray(); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/AttachmentSummaryService.cs new file mode 100644 index 0000000000..eb8f84be83 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/AttachmentSummaryService.cs @@ -0,0 +1,104 @@ +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.AI.Requests; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Intakes; +using Volo.Abp.DependencyInjection; + +namespace Unity.GrantManager.AI.Operations; + +public class AttachmentSummaryService( + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + ISubmissionAppService submissionAppService, + IAIService aiService, + ILogger logger) : IAttachmentSummaryService, ITransientDependency +{ + private const string DefaultContentType = "application/octet-stream"; + private const string SummaryGenerationFailedMessage = "AI summary generation failed."; + + public async Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null) + { + var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; + var (fileContent, contentType) = await GetAttachmentContentForSummaryAsync(attachment, fileName); + + var summaryResponse = await aiService.GenerateAttachmentSummaryAsync(new AttachmentSummaryRequest + { + FileName = fileName, + FileContent = fileContent, + ContentType = contentType, + PromptVersion = promptVersion, + }); + + attachment.AISummary = summaryResponse.Summary; + await applicationChefsFileAttachmentRepository.UpdateAsync(attachment); + + return summaryResponse.Summary; + } + + public async Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null) + { + var summaries = new List(); + + foreach (var attachmentId in attachmentIds) + { + try + { + summaries.Add(await GenerateAndSaveAsync(attachmentId, promptVersion)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error generating AI summary for attachment {AttachmentId}", attachmentId); + summaries.Add(SummaryGenerationFailedMessage); + } + } + + return summaries; + } + + public async Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null) + { + var attachmentIds = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) + .Select(a => a.Id) + .ToList(); + + return await GenerateAndSaveAsync(attachmentIds, promptVersion); + } + + private async Task<(byte[] Content, string ContentType)> GetAttachmentContentForSummaryAsync(ApplicationChefsFileAttachment attachment, string fileName) + { + if (!Guid.TryParse(attachment.ChefsSubmissionId, out var submissionId) || + !Guid.TryParse(attachment.ChefsFileId, out var fileId)) + { + logger.LogWarning( + "Attachment {AttachmentId} has invalid CHEFS IDs. Falling back to metadata-only summary generation.", + attachment.Id); + return (Array.Empty(), DefaultContentType); + } + + try + { + var fileDto = await submissionAppService.GetChefsFileAttachment(submissionId, fileId, fileName); + if (fileDto?.Content == null) + { + logger.LogWarning( + "Attachment {AttachmentId} has no retrievable content. Falling back to metadata-only summary generation.", + attachment.Id); + return (Array.Empty(), DefaultContentType); + } + + return (fileDto.Content, string.IsNullOrWhiteSpace(fileDto.ContentType) ? DefaultContentType : fileDto.ContentType); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed retrieving CHEFS content for attachment {AttachmentId}. Falling back to metadata-only summary generation.", + attachment.Id); + return (Array.Empty(), DefaultContentType); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IApplicationAnalysisService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IApplicationAnalysisService.cs new file mode 100644 index 0000000000..38eb57b046 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IApplicationAnalysisService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.GrantManager.AI.Operations +{ + public interface IApplicationAnalysisService + { + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IApplicationScoringService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IApplicationScoringService.cs new file mode 100644 index 0000000000..ff76afb866 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IApplicationScoringService.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.GrantManager.AI.Operations +{ + public interface IApplicationScoringService + { + Task RegenerateAndSaveAsync(Guid applicationId, string? promptVersion = null); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IAttachmentSummaryService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IAttachmentSummaryService.cs new file mode 100644 index 0000000000..a4cdfd8f9f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Operations/IAttachmentSummaryService.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.GrantManager.AI.Operations; + +public interface IAttachmentSummaryService +{ + Task GenerateAndSaveAsync(Guid attachmentId, string? promptVersion = null); + Task> GenerateAndSaveAsync(IEnumerable attachmentIds, string? promptVersion = null); + Task> GenerateForApplicationAsync(Guid applicationId, string? promptVersion = null); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AIPromptTypes.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AIPromptTypes.cs new file mode 100644 index 0000000000..47870da3f6 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AIPromptTypes.cs @@ -0,0 +1,8 @@ +namespace Unity.GrantManager.AI.Prompts; + +public static class AIPromptTypes +{ + public const string AttachmentSummary = "AttachmentSummary"; + public const string ApplicationAnalysis = "ApplicationAnalysis"; + public const string ApplicationScoring = "ApplicationScoring"; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs deleted file mode 100644 index 2e54412806..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs +++ /dev/null @@ -1,126 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class AnalysisPrompts - { - public const string DefaultRubric = @"BC GOVERNMENT GRANT EVALUATION RUBRIC: - -1. ELIGIBILITY REQUIREMENTS: - - Project must align with program objectives - - Applicant must be eligible entity type - - Budget must be reasonable and well-justified - - Project timeline must be realistic - -2. COMPLETENESS CHECKS: - - All required fields completed - - Necessary supporting documents provided - - Budget breakdown detailed and accurate - - Project description clear and comprehensive - -3. FINANCIAL REVIEW: - - Requested amount is within program limits - - Budget is reasonable for scope of work - - Matching funds or in-kind contributions identified - - Cost per outcome/beneficiary is reasonable - -4. RISK ASSESSMENT: - - Applicant capacity to deliver project - - Technical feasibility of proposed work - - Environmental or regulatory compliance - - Potential for cost overruns or delays - -5. QUALITY INDICATORS: - - Clear project objectives and outcomes - - Well-defined target audience/beneficiaries - - Appropriate project methodology - - Sustainability plan for long-term impact - -EVALUATION CRITERIA: -- HIGH: Meets all requirements, well-prepared application, low risk -- MEDIUM: Meets most requirements, minor issues or missing elements -- LOW: Missing key requirements, significant concerns, high risk"; - - public const string ScoreRules = @"HIGH: Application demonstrates strong evidence across most rubric areas with few or no issues. -MEDIUM: Application has some gaps or weaknesses that require reviewer attention. -LOW: Application has significant gaps or risks across key rubric areas."; - - public const string SeverityRules = @"ERROR: Issue that would likely prevent the application from being approved. -WARNING: Issue that could negatively affect the application's approval. -RECOMMENDATION: Reviewer-facing improvement or follow-up consideration."; - - public const string OutputTemplate = @"{ - ""rating"": ""HIGH/MEDIUM/LOW"", - ""warnings"": [ - { - ""title"": ""Brief summary of the warning"", - ""detail"": ""Detailed warning message with full context and explanation"" - } - ], - ""errors"": [ - { - ""title"": ""Brief summary of the error"", - ""detail"": ""Detailed error message with full context and explanation"" - } - ], - ""summaries"": [ - { - ""title"": ""Brief summary of the recommendation"", - ""detail"": ""Detailed recommendation with specific actionable guidance"" - } - ], - ""dismissed"": [] -}"; - - public const string Rules = @"- Use only SCHEMA, DATA, ATTACHMENTS, and RUBRIC as evidence. -- Do not invent fields, documents, requirements, or facts. -- Treat missing or empty values as findings only when they weaken rubric evidence. -- Prefer material issues; avoid nitpicking. -- Each error/warning/recommendation must describe one concrete issue or consideration and why it matters. -- Use 3-6 words for title. -- Each detail must be 1-2 complete sentences. -- Each detail must be grounded in concrete evidence from provided inputs. -- If attachment evidence is used, reference the attachment explicitly in detail. -- Do not provide applicant-facing advice. -- Do not mention rubric section names in findings. -- If no findings exist, return empty arrays. -- rating must be HIGH, MEDIUM, or LOW." - + "\n" + PromptCoreRules.ExactOutputShape - + "\n" + PromptCoreRules.NoExtraOutputKeys - + "\n" + PromptCoreRules.ValidJsonOnly - + "\n" + PromptCoreRules.PlainJsonOnly; - - public static readonly string SystemPrompt = PromptHeader.Build( - "You are an expert grant analyst assistant for human reviewers.", - "Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SEVERITY, SCORE, OUTPUT, and RULES, return review findings."); - - public static string BuildUserPrompt( - string schemaJson, - string dataJson, - string attachmentsJson, - string rubric) - { - return $@"SCHEMA -{schemaJson} - -DATA -{dataJson} - -ATTACHMENTS -{attachmentsJson} - -RUBRIC -{rubric} - -SEVERITY -{SeverityRules} - -SCORE -{ScoreRules} - -OUTPUT -{OutputTemplate} - -RULES -{Rules}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs deleted file mode 100644 index 969480ea86..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class AttachmentPrompts - { - public static readonly string SystemPrompt = PromptHeader.Build( - "You are a professional grant analyst for the BC Government.", - "Produce a concise reviewer-facing summary of the provided attachment context."); - - public const string OutputSection = @"OUTPUT -- Plain text only -- 1-2 complete sentences"; - - public const string RulesSection = @"RULES -- Use only the provided attachment context as evidence. -- If text content is present, summarize the actual content. -- If text content is missing or empty, provide a conservative metadata-based summary. -- Do not invent missing details. -- Keep the summary specific, concrete, and reviewer-facing. -- Return plain text only (no markdown, bullets, or JSON)."; - - public static string BuildUserPrompt(string attachmentPayloadJson) - { - return $@"ATTACHMENT -{attachmentPayloadJson}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptCoreRules.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptCoreRules.cs deleted file mode 100644 index e11dce3c97..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptCoreRules.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class PromptCoreRules - { - public const string UseProvidedEvidence = "- Use only provided input sections as evidence."; - public const string NoInvention = "- Do not invent missing details."; - public const string MinimumNarrativeWords = "- Any narrative text response must be at least 12 words."; - public const string ExactOutputShape = "- Return values exactly as specified in OUTPUT."; - public const string NoExtraOutputKeys = "- Do not return keys outside OUTPUT."; - public const string ValidJsonOnly = "- Return valid JSON only."; - public const string PlainJsonOnly = "- Return plain JSON only (no markdown)."; - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptDataPayloadBuilder.cs new file mode 100644 index 0000000000..c24166e354 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptDataPayloadBuilder.cs @@ -0,0 +1,221 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Unity.GrantManager.Applications; + +namespace Unity.GrantManager.AI.Prompts +{ + internal static class PromptDataPayloadBuilder + { + private static readonly string[] ExcludedPromptDataKeys = + { + "simplefile", + "applicantAgent", + "submit", + "lateEntry", + "metadata", + "full_application_form_submission", + "files", + "file", + "attachments" + }; + + private static readonly HashSet NonDataComponentTypes = new(StringComparer.OrdinalIgnoreCase) + { + "button", + "simplebuttonadvanced", + "html", + "htmlelement", + "content", + "simpleseparator" + }; + + public static JsonElement BuildPromptDataPayload( + Application application, + ApplicationFormSubmission? formSubmission, + string? formSchema, + ILogger logger) + { + var fallbackPayload = BuildFallbackPromptDataPayload(application); + if (TryBuildPromptDataValues(formSubmission?.Submission, formSchema, out var values, out var exception)) + { + return JsonSerializer.SerializeToElement(values); + } + + if (exception != null) + { + logger.LogWarning( + exception, + "Failed to parse form submission JSON for prompt payload generation for application {ApplicationId}.", + application.Id); + } + + return JsonSerializer.SerializeToElement(fallbackPayload); + } + + private static object BuildFallbackPromptDataPayload(Application application) + { + var notSpecified = "Not specified"; + return new + { + project_name = application.ProjectName, + reference_number = application.ReferenceNo, + requested_amount = application.RequestedAmount, + total_project_budget = application.TotalProjectBudget, + project_summary = application.ProjectSummary ?? "Not provided", + city = application.City ?? notSpecified, + economic_region = application.EconomicRegion ?? notSpecified, + community = application.Community ?? notSpecified, + project_start_date = application.ProjectStartDate, + project_end_date = application.ProjectEndDate, + submission_date = application.SubmissionDate + }; + } + + private static bool TryBuildPromptDataValues( + string? submissionJson, + string? formSchema, + out Dictionary values, + out Exception? exception) + { + values = new Dictionary(StringComparer.OrdinalIgnoreCase); + exception = null; + + if (string.IsNullOrWhiteSpace(submissionJson)) + { + return false; + } + + try + { + using var submissionDoc = JsonDocument.Parse(submissionJson); + if (!TryExtractSubmissionDataObject(submissionDoc.RootElement, out var submissionData)) + { + return false; + } + + values = BuildPromptDataValues(submissionData, formSchema); + return true; + } + catch (Exception ex) + { + exception = ex; + return false; + } + } + + private static bool TryExtractSubmissionDataObject(JsonElement root, out JsonElement submissionData) + { + submissionData = root; + if (root.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (root.TryGetProperty("data", out var dataElement) && dataElement.ValueKind == JsonValueKind.Object) + { + submissionData = dataElement; + return true; + } + + if (root.TryGetProperty("submission", out var submissionElement) && + submissionElement.ValueKind == JsonValueKind.Object && + submissionElement.TryGetProperty("data", out var nestedDataElement) && + nestedDataElement.ValueKind == JsonValueKind.Object) + { + submissionData = nestedDataElement; + return true; + } + + return true; + } + + private static Dictionary BuildPromptDataValues(JsonElement submissionData, string? formSchema) + { + var deserializedValues = JsonSerializer.Deserialize>(submissionData.GetRawText()) ?? + new Dictionary(); + var values = new Dictionary(deserializedValues, StringComparer.OrdinalIgnoreCase); + var allowedSchemaKeys = ExtractAllowedSchemaKeys(formSchema); + + foreach (var excludedKey in ExcludedPromptDataKeys) + { + values.Remove(excludedKey); + } + + if (allowedSchemaKeys.Count > 0) + { + values = values.Where(kvp => allowedSchemaKeys.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); + } + + return values; + } + + private static HashSet ExtractAllowedSchemaKeys(string? formSchema) + { + if (string.IsNullOrWhiteSpace(formSchema)) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + try + { + var schema = JObject.Parse(formSchema); + if (schema["components"] is not JArray components) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + var keys = new HashSet(StringComparer.OrdinalIgnoreCase); + ExtractSchemaKeys(components, keys); + return keys; + } + catch + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + + private static void ExtractSchemaKeys(JArray components, HashSet keys) + { + foreach (var component in components.OfType()) + { + var key = component["key"]?.ToString(); + var type = component["type"]?.ToString(); + var isInput = component["input"]?.Value() == true; + + if (!string.IsNullOrWhiteSpace(key) && + !string.IsNullOrWhiteSpace(type) && + !NonDataComponentTypes.Contains(type) && + isInput) + { + keys.Add(key); + } + + ProcessNestedSchemaComponents(component, keys); + } + } + + private static void ProcessNestedSchemaComponents(JObject component, HashSet keys) + { + if (component["components"] is JArray nestedComponents) + { + ExtractSchemaKeys(nestedComponents, keys); + } + + if (component["columns"] is JArray columns) + { + foreach (var column in columns.OfType()) + { + if (column["components"] is JArray columnComponents) + { + ExtractSchemaKeys(columnComponents, keys); + } + } + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptHeader.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptHeader.cs deleted file mode 100644 index 701a43e740..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/PromptHeader.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class PromptHeader - { - public static string Build(string role, string task) - { - return $@"ROLE -{role} - -TASK -{task}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs deleted file mode 100644 index 2db4de742d..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace Unity.GrantManager.AI -{ - internal static class ScoresheetPrompts - { - public static readonly string SectionSystemPrompt = PromptHeader.Build( - "You are an expert grant application reviewer for the BC Government.", - "Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES, answer only the questions in SECTION."); - - public const string SectionOutputTemplate = @"{ - """": { - ""answer"": """", - ""rationale"": """", - ""confidence"": - } -}"; - - public const string SectionRules = "- Use only DATA and ATTACHMENTS as evidence.\n" - + "- Do not invent missing application details.\n" - + @"- Return exactly one answer object per question ID in SECTION.questions. -- Do not omit any question IDs from SECTION.questions. -- Do not add keys that are not question IDs from SECTION.questions. -- Use RESPONSE as the output contract and fill every placeholder value. -- Follow this process in order: (1) copy RESPONSE, (2) iterate SECTION.questions in order, (3) fill answer+rationale+confidence for each matching question ID, (4) run final completeness check. -- Each answer object must include: ""answer"", ""rationale"", and ""confidence"". -- Never omit ""answer"", ""rationale"", or ""confidence"" for any question type. -- The ""answer"" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. -- The ""rationale"" field must be 1-2 complete sentences and grounded in concrete DATA/ATTACHMENTS evidence. -- In ""rationale"", cite concrete source evidence from the provided input content; do not cite prompt section headers. -- For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. -- If explicit evidence is insufficient, choose the most conservative valid answer and state uncertainty in rationale. -- Do not treat missing or non-contradictory information as evidence. -- The ""confidence"" field must be an integer from 0 to 100 in increments of 5 and represents confidence in the selected answer. -- Set confidence by certainty of the selected answer based on available evidence, regardless of which option is selected. -- For yes/no questions, the ""answer"" field must be exactly ""Yes"" or ""No"". -- For numeric questions, answer must be a numeric value within the allowed range. -- For numeric questions, answer must never be blank. -- If evidence is insufficient for a numeric question, return the minimum allowed numeric value and explain uncertainty in rationale. -- If a required value is explicitly missing in DATA/ATTACHMENTS, set confidence high (80-100) when selecting the conservative minimum. -- For select list questions, return only the selected options.number as a string (the option index shown in options), never label text or points. -- For select list questions, the ""answer"" value must be one of question.allowed_answers exactly. -- Never return 0 for select list answers unless 0 exists as an explicit option number. -- For text and text area questions, answer must be concise, evidence-based, non-empty, and avoid boilerplate placeholders. -- For text and text area questions, answer is the reviewer comment, and rationale must explain the evidence basis and certainty for that comment. -- For comment fields, summarize key evidence-based conclusions from the other questions in SECTION, including uncertainty where applicable. -- Do not leave rationale empty when answer is populated. -- Final self-check before responding: every question ID in RESPONSE must have a non-empty ""answer"", non-empty ""rationale"", and ""confidence"". -- If any answer object is incomplete, regenerate the full JSON response before returning it. -" - + PromptCoreRules.MinimumNarrativeWords + "\n" - + PromptCoreRules.ExactOutputShape + "\n" - + PromptCoreRules.NoExtraOutputKeys + "\n" - + PromptCoreRules.ValidJsonOnly + "\n" - + PromptCoreRules.PlainJsonOnly; - - public static string BuildSectionUserPrompt( - string applicationContent, - string attachmentSummariesText, - string sectionPayloadJson, - string responseTemplateJson) - { - return $@"DATA -{applicationContent} - -ATTACHMENTS -- {attachmentSummariesText} - -SECTION -{sectionPayloadJson} - -RESPONSE -{responseTemplateJson} - -OUTPUT -{SectionOutputTemplate} - -RULES -{SectionRules}"; - } - } -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md new file mode 100644 index 0000000000..3cc30965e8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md @@ -0,0 +1,55 @@ +# Runtime Prompt Templates + +These files are the source of truth for runtime prompts. +`OpenAIRuntimeService` resolves templates from: + +- `AI/Prompts/Versions//
@column@Model.Columns[i]Actions