From 7a6b71d8f34ad5d3d693057826cd770193cc9a46 Mon Sep 17 00:00:00 2001 From: Velang Date: Thu, 5 Mar 2026 15:48:51 -0800 Subject: [PATCH 001/245] initial commit --- applications/Unity.AutoUI/cypress.config.ts | 1 + .../cypress/fixtures/test-attachment.txt | 3 + .../cypress/pages/ApplicationDetailsPage.ts | 187 ++++- .../pages/ApplicationDetailsRightTabPage.ts | 644 ++++++++++++++++++ .../cypress/pages/ApplicationsListPage.ts | 33 + .../cypress/pages/ReviewAssessmentPage.ts | 445 ++++++++++++ .../cypress/regression/ApprovalFlow.cy.ts | 159 +++++ .../Unity.AutoUI/cypress/support/e2e.ts | 21 +- applications/Unity.AutoUI/tsconfig.json | 2 +- 9 files changed, 1481 insertions(+), 14 deletions(-) create mode 100644 applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt create mode 100644 applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts create mode 100644 applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts create mode 100644 applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts diff --git a/applications/Unity.AutoUI/cypress.config.ts b/applications/Unity.AutoUI/cypress.config.ts index c227e4bc3d..40c9904fc7 100644 --- a/applications/Unity.AutoUI/cypress.config.ts +++ b/applications/Unity.AutoUI/cypress.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ setupNodeEvents(on, config) { // implement node event listeners here }, + specPattern: ['cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', 'cypress/regression/**/*.cy.{js,jsx,ts,tsx}'], baseUrl: 'https://developer.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. 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..bd9fee47ca --- /dev/null +++ b/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt @@ -0,0 +1,3 @@ +This is a test attachment file for automated regression testing. +Created for Unity AutoUI Cypress tests. +Date: Auto-generated during test execution. diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 6194b02f1f..865a4cb55d 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,62 @@ 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.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; } /** @@ -168,7 +182,7 @@ export class ApplicationDetailsPage extends BasePage { | "applicantInfo" | "fundingAgreement" | "paymentInfo" - ): void { + ): this { const tabSelectors: Record = { submission: this.tabs.submission, reviewAssessment: this.tabs.reviewAssessment, @@ -178,6 +192,7 @@ export class ApplicationDetailsPage extends BasePage { paymentInfo: this.tabs.paymentInfo, }; cy.get(tabSelectors[tabName]).should("have.class", "active"); + return this; } /** @@ -294,6 +309,127 @@ 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 + // Force close modal via JavaScript if still open + cy.window().then((win) => { + // Remove all modals and backdrops + win.document.querySelectorAll(".modal.show, .modal.fade.show").forEach((el) => { + (el as HTMLElement).classList.remove("show"); + (el as HTMLElement).style.display = "none"; + }); + win.document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove()); + win.document.body.classList.remove("modal-open"); + win.document.body.style.removeProperty("overflow"); + win.document.body.style.removeProperty("padding-right"); + }); + cy.wait(500); + 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 ============ /** @@ -339,9 +475,10 @@ export class ApplicationDetailsPage extends BasePage { /** * Click Approve action */ - clickApprove(): void { + clickApprove(): this { this.openStatusActionsDropdown(); this.clickElement(this.statusActions.approve); + return this; } /** @@ -384,6 +521,38 @@ 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; + } + /** * Verify status action is enabled */ 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..4d27a480c0 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/pages/ReviewAssessmentPage.ts @@ -0,0 +1,445 @@ +/// + +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 today = new Date().toISOString().split("T")[0]; + 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..afe0b3ff38 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -0,0 +1,159 @@ +/// + +/** + * Sample Regression Test - Full Approval Workflow + * + * This test validates the complete application approval workflow including: + * - Searching and opening a submission + * - Review and assessment process + * - Payment info configuration + * - Adding comments and attachments + * - Approval action (cancelled for test purposes) + */ + +import { ApplicationsListPage } from "../pages/ApplicationsListPage"; +import { ApplicationDetailsPage } from "../pages/ApplicationDetailsPage"; +import { ReviewAssessmentPage } from "../pages/ReviewAssessmentPage"; +import { ApplicationDetailsRightTabPage } from "../pages/ApplicationDetailsRightTabPage"; +import { loginIfNeeded } from "../support/auth"; + +// ============ Test Configuration ============ +// These values can be modified to test different submissions +const TEST_CONFIG = { + submissionId: "84A888BD", + grantProgram: "Default Grants Program", + approvedAmount: "5000", + supplierNumber: "2002712", + paymentGroup: "Cheque" as const, + testComment: "Test comment from automated regression test", +}; + +describe("Sample 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(); + + before(() => { + Cypress.config("includeShadowDom", true); + loginIfNeeded(); + }); + + // ============ Navigation & Search ============ + + it("Switch to grant program", () => { + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + }); + + it("Search for submission", () => { + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(TEST_CONFIG.submissionId); + }); + + it("Select submission and open details", () => { + listPage + .selectRowByText(TEST_CONFIG.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", () => { + reviewPage + .scrollToAssessmentList() + .clickCreateAssessment() + .clickCompleteAssessment(); + }); + + // ============ Payment Info ============ + + it("Configure payment info", () => { + 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", () => { + rightTabPage + .goToCommentsTab() + .addComment(TEST_CONFIG.testComment) + .clickSaveComment(); + }); + + it("Add an attachment", () => { + rightTabPage.goToAttachmentsTab(); + cy.wait(1000); // Allow tab content to load + + // Store initial count to verify upload + rightTabPage.getAttachmentsCount().then((initialCount) => { + cy.log(`Initial attachment count: ${initialCount}`); + + // Generate unique filename to ensure new file is added + const timestamp = Date.now(); + const uniqueFileName = `test-attachment-${timestamp}.txt`; + + // Upload file with unique content + rightTabPage.uploadUniqueAttachment(uniqueFileName, timestamp); + + // Verify upload success + cy.contains("Successful").should("be.visible"); + cy.wait(2000); // Allow UI to update + + // Verify count increased + rightTabPage.getAttachmentsCount().then((newCount) => { + cy.log(`New attachment count: ${newCount}`); + expect(newCount).to.be.greaterThan(initialCount); + }); + + // Verify file appears in list + rightTabPage.verifyAttachmentExists(uniqueFileName); + cy.screenshot("attachment-upload-complete"); + }); + }); + + // ============ Approval Action ============ + + it("Test approval workflow (cancel)", () => { + detailsPage + .clickApprove() + .waitForConfirmModal() + .clickCancel(); + }); + + // ============ Cleanup ============ + + it("Logout", () => { + cy.logout(); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/support/e2e.ts b/applications/Unity.AutoUI/cypress/support/e2e.ts index 860eac1b25..576a0a1c00 100644 --- a/applications/Unity.AutoUI/cypress/support/e2e.ts +++ b/applications/Unity.AutoUI/cypress/support/e2e.ts @@ -8,12 +8,25 @@ 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 - return true + // 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 false to prevent test failure for other uncaught exceptions + // Change to true if you want tests to fail on unexpected errors + return false }) diff --git a/applications/Unity.AutoUI/tsconfig.json b/applications/Unity.AutoUI/tsconfig.json index 89a0c8e415..d64c038488 100644 --- a/applications/Unity.AutoUI/tsconfig.json +++ b/applications/Unity.AutoUI/tsconfig.json @@ -106,5 +106,5 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, // Included or excluded files or folders... https://www.typescriptlang.org/tsconfig - "include": ["cypress/support/**/*.ts", "cypress/e2e/**/*.ts", "cypress/pages/**/*.ts"] + "include": ["cypress/support/**/*.ts", "cypress/e2e/**/*.ts", "cypress/pages/**/*.ts", "cypress/regression/**/*.ts"] } From b63a8fe94078ec0e10cf0b273b70dedaafcd341f Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 6 Mar 2026 12:08:51 -0800 Subject: [PATCH 002/245] feature/AB#32216-BuildAIBase --- .../Unity.GrantManager/Unity.GrantManager.sln | 31 + .../modules/Unity.AI/.gitattributes | 1 + .../modules/Unity.AI/.gitignore | 262 ++ .../modules/Unity.AI/.prettierrc | 5 + .../modules/Unity.AI/NuGet.Config | 5 + .../modules/Unity.AI/common.props | 24 + .../AIApplicationContractsModule.cs | 15 + .../AIRemoteServiceConsts.cs | 7 + .../FodyWeavers.xml | 3 + .../FodyWeavers.xsd | 30 + .../AIPermissionDefinitionProvider.cs | 20 + .../Permissions/AIPermissions.cs | 11 + .../Unity.AI.Application.Contracts.csproj | 25 + .../src/Unity.AI.Application/AIAppService.cs | 11 + .../AIApplicationAutoMapperProfile.cs | 11 + .../AIApplicationModule.cs | 56 + .../Domain/AIDbProperties.cs | 16 + .../EntityFrameworkCore/AIDbContext.cs | 17 + .../AIDbContextModelCreatingExtensions.cs | 20 + .../AIEntityFrameworkCoreModule.cs | 15 + .../EntityFrameworkCore/IAIDbContext.cs | 11 + .../src/Unity.AI.Application/FodyWeavers.xml | 3 + .../src/Unity.AI.Application/FodyWeavers.xsd | 30 + .../Unity.AI.Application.csproj | 31 + .../Unity.AI.Domain.Shared/AIErrorCodes.cs | 7 + .../Unity.AI.Domain.Shared/AISharedModule.cs | 40 + .../Unity.AI.Domain.Shared/FodyWeavers.xml | 3 + .../Unity.AI.Domain.Shared/FodyWeavers.xsd | 30 + .../Localization/AI/en.json | 7 + .../Localization/AIResource.cs | 9 + .../Unity.AI.Shared.csproj | 34 + .../Unity.AI.Web/AIWebAutoMapperProfile.cs | 11 + .../Unity.AI/src/Unity.AI.Web/AIWebModule.cs | 44 + .../Unity.AI/src/Unity.AI.Web/FodyWeavers.xml | 3 + .../Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd | 30 + .../Unity.AI.Web/Pages/_ViewImports.cshtml | 6 + .../Properties/launchSettings.json | 12 + .../src/Unity.AI.Web/Unity.AI.Web.csproj | 42 + .../GrantTenantDbContext.cs | 2 + .../20260306195601_AISchema.Designer.cs | 2694 +++++++++++++++++ .../HostMigrations/20260306195601_AISchema.cs | 21 + .../GrantManagerDbContextModelSnapshot.cs | 101 +- ...ty.GrantManager.EntityFrameworkCore.csproj | 1 + .../Unity.GrantManager.Web.csproj | 1 + 44 files changed, 3742 insertions(+), 16 deletions(-) create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/.gitattributes create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/.gitignore create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/.prettierrc create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/NuGet.Config create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/common.props create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIRemoteServiceConsts.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xml create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/FodyWeavers.xsd create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIAppService.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xml create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/FodyWeavers.xsd create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AIErrorCodes.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AISharedModule.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xml create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/FodyWeavers.xsd create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AIResource.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebAutoMapperProfile.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xml create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/FodyWeavers.xsd create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/_ViewImports.cshtml create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Properties/launchSettings.json create mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.cs diff --git a/applications/Unity.GrantManager/Unity.GrantManager.sln b/applications/Unity.GrantManager/Unity.GrantManager.sln index 567d2f71c0..72ef37a99f 100644 --- a/applications/Unity.GrantManager/Unity.GrantManager.sln +++ b/applications/Unity.GrantManager/Unity.GrantManager.sln @@ -155,6 +155,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.Flex.Web.Tests", "mod 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}" 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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.AI.Web", "modules\Unity.AI\src\Unity.AI.Web\Unity.AI.Web.csproj", "{378A4EB8-3DC1-420E-98B5-798DE71BEF0D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -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..f9e53089f8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AIApplicationContractsModule.cs @@ -0,0 +1,15 @@ +using Volo.Abp.Application; +using Volo.Abp.Modularity; +using Volo.Abp.Authorization; + +namespace Unity.AI; + +[DependsOn( + typeof(AIDomainSharedModule), + typeof(AbpDddApplicationContractsModule), + typeof(AbpAuthorizationModule) + )] +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..93f756b0af --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -0,0 +1,20 @@ +using Unity.AI.Localization; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Localization; + +namespace Unity.AI.Permissions; + +public class AIPermissionDefinitionProvider : PermissionDefinitionProvider +{ + public override void Define(IPermissionDefinitionContext context) + { + var aiPermissionsGroup = context.AddGroup(AIPermissions.GroupName, L("Permission:AI")); + + aiPermissionsGroup.AddPermission(AIPermissions.Default.Management, L("Permission:AI.Default")); + } + + 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..8e33c15418 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -0,0 +1,11 @@ +namespace Unity.AI.Permissions; + +public static class AIPermissions +{ + public const string GroupName = "AI"; + + public static class Default + { + public const string Management = GroupName + ".Management"; + } +} 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..78bb965745 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj @@ -0,0 +1,25 @@ + + + + + + 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..874ac789db --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationAutoMapperProfile.cs @@ -0,0 +1,11 @@ +using AutoMapper; + +namespace Unity.AI; + +public class AIApplicationAutoMapperProfile : Profile +{ + public AIApplicationAutoMapperProfile() + { + // Define AutoMapper mappings here as entities and DTOs are introduced + } +} 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..60974f22b4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs @@ -0,0 +1,56 @@ +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); + }); + } +} 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..6322b8e558 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIDbProperties.cs @@ -0,0 +1,16 @@ +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"; + + /// + /// Shares the Tenant connection string so no additional database infrastructure is required. + /// + public const string ConnectionStringName = "Tenant"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs new file mode 100644 index 0000000000..c12e411210 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Volo.Abp.Data; +using Volo.Abp.EntityFrameworkCore; +using Unity.AI.Domain; + +namespace Unity.AI.EntityFrameworkCore; + +[ConnectionStringName(AIDbProperties.ConnectionStringName)] +public class AIDbContext(DbContextOptions options) : AbpDbContext(options), IAIDbContext +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.ConfigureAI(); + } +} 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..60b243de5d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Volo.Abp; + +namespace Unity.AI.EntityFrameworkCore; + +public static class AIDbContextModelCreatingExtensions +{ + public static void ConfigureAI(this ModelBuilder modelBuilder) + { + Check.NotNull(modelBuilder, nameof(modelBuilder)); + + // Configure AI entities here as they are introduced. + // Example: + // modelBuilder.Entity(b => + // { + // b.ToTable(AIDbProperties.DbTablePrefix + "SomeEntities", AIDbProperties.DbSchema); + // b.ConfigureByConvention(); + // }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs new file mode 100644 index 0000000000..9bf02d58ee --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Modularity; + +namespace Unity.AI.EntityFrameworkCore; + +public class AIEntityFrameworkCoreModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddAbpDbContext(options => + { + /* Add custom repositories here */ + }); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs new file mode 100644 index 0000000000..2085700861 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Data; +using Volo.Abp.EntityFrameworkCore; +using Unity.AI.Domain; + +namespace Unity.AI.EntityFrameworkCore; + +[ConnectionStringName(AIDbProperties.ConnectionStringName)] +public interface IAIDbContext : IEfCoreDbContext +{ + // Add DbSet properties here as entities are introduced +} 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/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/AIErrorCodes.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AIErrorCodes.cs new file mode 100644 index 0000000000..4dee945346 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AIErrorCodes.cs @@ -0,0 +1,7 @@ +namespace Unity.AI; + +public static class AIErrorCodes +{ + // Define module error codes here + // Example: public const string SomeError = "AI:00001"; +} 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..141589bbcb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -0,0 +1,7 @@ +{ + "culture": "en", + "texts": { + "Permission:AI": "AI", + "Permission:AI.Default": "AI Management" + } +} 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/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..e82f53daa7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/AIWebModule.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.DependencyInjection; +using Unity.AI.Localization; +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.VirtualFileSystem; + +namespace Unity.AI.Web; + +[DependsOn( + typeof(AIApplicationContractsModule), + typeof(AbpAspNetCoreMvcUiThemeSharedModule), + typeof(AbpAutoMapperModule) + )] +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(); + }); + + context.Services.AddAutoMapperObjectMapper(); + Configure(options => + { + options.AddMaps(validate: true); + }); + } +} 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/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..b1233b386d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj @@ -0,0 +1,42 @@ + + + + + + net9.0 + enable + true + Library + Unity.AI.Web + true + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs index ee2bdd7ed2..31f7143595 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantTenantDbContext.cs @@ -14,6 +14,7 @@ using Unity.Flex.EntityFrameworkCore; using Unity.Notifications.EntityFrameworkCore; using Unity.Reporting.EntityFrameworkCore; +using Unity.AI.EntityFrameworkCore; using Unity.GrantManager.GlobalTag; using Unity.GrantManager.Contacts; @@ -379,6 +380,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ConfigureFlex(); modelBuilder.ConfigureNotifications(); modelBuilder.ConfigureReporting(); + modelBuilder.ConfigureAI(); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.Designer.cs new file mode 100644 index 0000000000..ff79404fd3 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.Designer.cs @@ -0,0 +1,2694 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + [DbContext(typeof(GrantManagerDbContext))] + [Migration("20260306195601_AISchema")] + partial class AISchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applicants.ApplicantTenantMap", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastUpdated") + .HasColumnType("timestamp without time zone"); + + b.Property("OidcSubUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TenantName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OidcSubUsername"); + + b.HasIndex("OidcSubUsername", "TenantId") + .IsUnique(); + + b.ToTable("ApplicantTenantMaps", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.CasClientCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialMinistry") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("MinistryPrefix") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.HasKey("Id"); + + b.ToTable("CasClientCodes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.DynamicUrl", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("KeyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DynamicUrls", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Community", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Communities", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.EconomicRegion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("EconomicRegionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("EconomicRegions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.ElectoralDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ElectoralDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ElectoralDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("ElectoralDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.RegionalDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RegionalDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorId") + .HasColumnType("uuid"); + + b.Property("SubSectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubSectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SectorId"); + + b.ToTable("SubSectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("ApplicationName"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("BrowserInfo"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientId"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientIpAddress"); + + b.Property("ClientName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ClientName"); + + b.Property("Comments") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Comments"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("CorrelationId"); + + b.Property("Exceptions") + .HasColumnType("text"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HttpMethod") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("HttpMethod"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("HttpStatusCode"); + + b.Property("ImpersonatorTenantId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorTenantId"); + + b.Property("ImpersonatorTenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ImpersonatorTenantName"); + + b.Property("ImpersonatorUserId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorUserId"); + + b.Property("ImpersonatorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ImpersonatorUserName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("TenantName"); + + b.Property("Url") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ExecutionTime"); + + b.HasIndex("TenantId", "UserId", "ExecutionTime"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ExecutionTime"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("MethodName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("MethodName"); + + b.Property("Parameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("Parameters"); + + b.Property("ServiceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ServiceName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "ServiceName", "MethodName", "ExecutionTime"); + + b.ToTable("AuditLogActions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ChangeTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ChangeTime"); + + b.Property("ChangeType") + .HasColumnType("smallint") + .HasColumnName("ChangeType"); + + b.Property("EntityId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityId"); + + b.Property("EntityTenantId") + .HasColumnType("uuid"); + + b.Property("EntityTypeFullName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityTypeFullName"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "EntityTypeFullName", "EntityId"); + + b.ToTable("EntityChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntityChangeId") + .HasColumnType("uuid"); + + b.Property("NewValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("NewValue"); + + b.Property("OriginalValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("OriginalValue"); + + b.Property("PropertyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("PropertyName"); + + b.Property("PropertyTypeFullName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("PropertyTypeFullName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("EntityChangeId"); + + b.ToTable("EntityPropertyChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.BackgroundJobs.BackgroundJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsAbandoned") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("JobArgs") + .IsRequired() + .HasMaxLength(1048576) + .HasColumnType("character varying(1048576)"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("NextTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)15); + + b.Property("TryCount") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0); + + b.HasKey("Id"); + + b.HasIndex("IsAbandoned", "NextTryTime"); + + b.ToTable("BackgroundJobs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedProviders") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DefaultValue") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsAvailableToHost") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ValueType") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Features", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("FeatureValues", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityClaimType", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsStatic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Regex") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RegexDescription") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClaimTypes", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityLinkUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("SourceTenantId") + .HasColumnType("uuid"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("TargetTenantId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceUserId", "SourceTenantId", "TargetUserId", "TargetTenantId") + .IsUnique(); + + b.ToTable("LinkUsers", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("IsDefault"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("IsPublic"); + + b.Property("IsStatic") + .HasColumnType("boolean") + .HasColumnName("IsStatic"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySecurityLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Action") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Identity") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Action"); + + b.HasIndex("TenantId", "ApplicationName"); + + b.HasIndex("TenantId", "Identity"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("SecurityLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySession", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Device") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeviceInfo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IpAddresses") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastAccessed") + .HasColumnType("timestamp without time zone"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SignedIn") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Device"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("Sessions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.Property("EmailConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("EmailConfirmed"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("IsActive"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsExternal") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsExternal"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastPasswordChangeTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("LockoutEnabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Name"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedEmail"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedUserName"); + + b.Property("OidcSub") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("PasswordHash"); + + b.Property("PhoneNumber") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("PhoneNumber"); + + b.Property("PhoneNumberConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("PhoneNumberConfirmed"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("SecurityStamp"); + + b.Property("ShouldChangePasswordOnNextLogin") + .HasColumnType("boolean"); + + b.Property("Surname") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Surname"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TwoFactorEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("TwoFactorEnabled"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("NormalizedEmail"); + + b.HasIndex("NormalizedUserName"); + + b.HasIndex("UserName"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserDelegation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("UserDelegations", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderDisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(196) + .HasColumnType("character varying(196)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "LoginProvider"); + + b.HasIndex("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "UserId"); + + b.HasIndex("UserId", "OrganizationUnitId"); + + b.ToTable("UserOrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId", "UserId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(95) + .HasColumnType("character varying(95)") + .HasColumnName("Code"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("DisplayName"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("ParentId"); + + b.ToTable("OrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "RoleId"); + + b.HasIndex("RoleId", "OrganizationUnitId"); + + b.ToTable("OrganizationUnitRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("MultiTenancySide") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateCheckers") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("PermissionGrants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PermissionGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.Setting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.SettingDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultValue") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInherited") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SettingDefinitions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("TenantId", "Name"); + + b.ToTable("TenantConnectionStrings", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.HasOne("Unity.GrantManager.Locality.Sector", "Sector") + .WithMany("SubSectors") + .HasForeignKey("SectorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sector"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("Actions") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("EntityChanges") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.EntityChange", null) + .WithMany("PropertyChanges") + .HasForeignKey("EntityChangeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("OrganizationUnits") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany("Roles") + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.HasOne("Volo.Abp.TenantManagement.Tenant", null) + .WithMany("ConnectionStrings") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Navigation("SubSectors"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Navigation("Actions"); + + b.Navigation("EntityChanges"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Navigation("PropertyChanges"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Navigation("Claims"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("OrganizationUnits"); + + b.Navigation("Roles"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Navigation("Roles"); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Navigation("ConnectionStrings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.cs new file mode 100644 index 0000000000..1c85f5cb8a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260306195601_AISchema.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + /// + public partial class AISchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "AI"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index f4cf613605..d034c14e8e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -472,16 +472,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); - b.Property("ClientCode") - .IsRequired() - .HasColumnType("text"); - - b.Property("ClientId") - .HasColumnType("text"); - b.Property("ConcurrencyStamp") + .IsConcurrencyToken() .IsRequired() - .HasColumnType("text"); + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); b.Property("CreationTime") .HasColumnType("timestamp without time zone") @@ -491,15 +487,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("CreatorId"); - b.Property("Description") + b.Property("ExtraProperties") .IsRequired() - .HasColumnType("text"); - - b.Property("FinancialMinistry") - .HasColumnType("text"); - - b.Property("IsActive") - .HasColumnType("boolean"); + .HasColumnType("text") + .HasColumnName("ExtraProperties"); b.Property("LastUpdated") .HasColumnType("timestamp without time zone"); @@ -525,6 +516,84 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ApplicantTenantMaps", (string)null); }); + modelBuilder.Entity("Unity.GrantManager.Integrations.CasClientCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialMinistry") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("MinistryPrefix") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.HasKey("Id"); + + b.ToTable("CasClientCodes", (string)null); + }); + modelBuilder.Entity("Unity.GrantManager.Integrations.DynamicUrl", b => { b.Property("Id") diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj index 6d06183c85..7a957daef7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Unity.GrantManager.EntityFrameworkCore.csproj @@ -43,6 +43,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj index 7849bfb978..f01ec57688 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Unity.GrantManager.Web.csproj @@ -25,6 +25,7 @@ + From fc2b30e5679fe71398962d3e159f214c3b7235d1 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 6 Mar 2026 12:50:53 -0800 Subject: [PATCH 003/245] feature/AB#32216-BuildAIBase-RemoveDBContext-ReviewFixes --- .../Permissions/AIPermissions.cs | 7 +++++++ .../Domain/AIDbProperties.cs | 5 ----- .../EntityFrameworkCore/AIDbContext.cs | 17 ----------------- .../AIEntityFrameworkCoreModule.cs | 15 --------------- .../EntityFrameworkCore/IAIDbContext.cs | 11 ----------- .../GrantManagerWebModule.cs | 4 +++- 6 files changed, 10 insertions(+), 49 deletions(-) delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs 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 index 8e33c15418..78b064262e 100644 --- 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 @@ -1,3 +1,5 @@ +using Volo.Abp.Reflection; + namespace Unity.AI.Permissions; public static class AIPermissions @@ -8,4 +10,9 @@ public static class Default { public const string Management = GroupName + ".Management"; } + + public static string[] GetAll() + { + return ReflectionHelper.GetPublicConstantsRecursively(typeof(AIPermissions)); + } } 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 index 6322b8e558..d786709167 100644 --- 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 @@ -8,9 +8,4 @@ public static class AIDbProperties /// Schema for Unity.AI tables — kept separate from other modules. /// public static string? DbSchema { get; set; } = "AI"; - - /// - /// Shares the Tenant connection string so no additional database infrastructure is required. - /// - public const string ConnectionStringName = "Tenant"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs deleted file mode 100644 index c12e411210..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Volo.Abp.Data; -using Volo.Abp.EntityFrameworkCore; -using Unity.AI.Domain; - -namespace Unity.AI.EntityFrameworkCore; - -[ConnectionStringName(AIDbProperties.ConnectionStringName)] -public class AIDbContext(DbContextOptions options) : AbpDbContext(options), IAIDbContext -{ - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.ConfigureAI(); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs deleted file mode 100644 index 9bf02d58ee..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIEntityFrameworkCoreModule.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Volo.Abp.Modularity; - -namespace Unity.AI.EntityFrameworkCore; - -public class AIEntityFrameworkCoreModule : AbpModule -{ - public override void ConfigureServices(ServiceConfigurationContext context) - { - context.Services.AddAbpDbContext(options => - { - /* Add custom repositories here */ - }); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs deleted file mode 100644 index 2085700861..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/IAIDbContext.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Volo.Abp.Data; -using Volo.Abp.EntityFrameworkCore; -using Unity.AI.Domain; - -namespace Unity.AI.EntityFrameworkCore; - -[ConnectionStringName(AIDbProperties.ConnectionStringName)] -public interface IAIDbContext : IEfCoreDbContext -{ - // Add DbSet properties here as entities are introduced -} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index caf91fc168..826590b751 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -75,6 +75,7 @@ using Unity.Modules.Shared.Utils; using Unity.Notifications.Web.Bundling; using Unity.Reporting.Web; +using Unity.AI.Web; using Unity.GrantManager.Web.Views.Settings; namespace Unity.GrantManager.Web; @@ -99,7 +100,8 @@ namespace Unity.GrantManager.Web; typeof(AbpBlobStoringModule), typeof(NotificationsWebModule), typeof(FlexWebModule), - typeof(ReportingWebModule) + typeof(ReportingWebModule), + typeof(AIWebModule) )] public class GrantManagerWebModule : AbpModule From 8c00f95775d9efe60302defc9bf0de47ac1fdd82 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 6 Mar 2026 13:38:04 -0800 Subject: [PATCH 004/245] feature/AB#32216-BuildAIBase-Permission:AI Already existed as a group --- .../AIPermissionDefinitionProvider.cs | 28 +++++++++++++++++-- .../Permissions/AIPermissions.cs | 22 +++++++++++++++ .../Unity.AI.Application.Contracts.csproj | 1 + .../Localization/AI/en.json | 6 +++- ...ApplicationPermissionDefinitionProvider.cs | 27 +----------------- .../Assessments/AssessmentAppService.cs | 6 ++-- .../Unity.GrantManager.Application.csproj | 1 + .../Localization/GrantManager/en.json | 5 ---- .../GrantApplicationPermissions.cs | 26 +---------------- .../Menus/GrantManagerMenuContributor.cs | 9 +++--- .../Pages/GrantApplications/Details.cshtml | 3 +- .../ChefsAttachments/ChefsAttachments.cs | 3 +- 12 files changed, 70 insertions(+), 67 deletions(-) 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 index 93f756b0af..8c56c25ffc 100644 --- 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 @@ -1,6 +1,7 @@ using Unity.AI.Localization; using Volo.Abp.Authorization.Permissions; using Volo.Abp.Localization; +using Volo.Abp.Features; namespace Unity.AI.Permissions; @@ -8,9 +9,32 @@ public class AIPermissionDefinitionProvider : PermissionDefinitionProvider { public override void Define(IPermissionDefinitionContext context) { - var aiPermissionsGroup = context.AddGroup(AIPermissions.GroupName, L("Permission:AI")); + // AI Permission Group + var aiPermissionsGroup = context.AddGroup( + AIPermissions.GroupName, + L("Permission:AI")); + + + aiPermissionsGroup.AddPermission( + AIPermissions.Default.Reporting.Default, + L("Permission:AI.Reporting")) + .RequireFeatures("Unity.AIReporting"); + + aiPermissionsGroup.AddPermission( + AIPermissions.Default.ApplicationAnalysis.Default, + L("Permission:AI.ApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); + + aiPermissionsGroup.AddPermission( + AIPermissions.Default.AttachmentSummary.Default , + L("Permission:AI.AttachmentSummary")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); + + aiPermissionsGroup.AddPermission( + AIPermissions.Default.ScoringAssistant.Default, + L("Permission:AI.ScoringAssistant")) + .RequireFeatures("Unity.AI.Scoring"); - aiPermissionsGroup.AddPermission(AIPermissions.Default.Management, L("Permission:AI.Default")); } private static LocalizableString L(string 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 index 78b064262e..8e94d7fe77 100644 --- 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 @@ -1,4 +1,5 @@ using Volo.Abp.Reflection; +using Volo.Abp.Features; namespace Unity.AI.Permissions; @@ -9,6 +10,27 @@ public static class AIPermissions public static class Default { public const string Management = GroupName + ".Management"; + public const string GroupName = "AI"; + + public static class Reporting + { + public const string Default = GroupName + ".Reporting"; + } + + public static class ApplicationAnalysis + { + public const string Default = GroupName + ".ApplicationAnalysis"; + } + + public static class AttachmentSummary + { + public const string Default = GroupName + ".AttachmentSummary"; + } + + public static class ScoringAssistant + { + public const string Default = GroupName + ".ScoringAssistant"; + } } public static string[] GetAll() 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 index 78bb965745..cd71888469 100644 --- 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 @@ -11,6 +11,7 @@ + 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 index 141589bbcb..775b9a4d87 100644 --- 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 @@ -2,6 +2,10 @@ "culture": "en", "texts": { "Permission:AI": "AI", - "Permission:AI.Default": "AI Management" + "Permission:AI.Default": "AI Management", + "Permission:AI.Reporting": "AI Reporting", + "Permission:AI.ApplicationAnalysis": "AI Application Analysis", + "Permission:AI.AttachmentSummary": "AI Attachment Summary", + "Permission:AI.ScoringAssistant": "AI Scoring Assistant" } } 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..d21954da46 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 @@ -115,32 +115,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/Assessments/AssessmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs index c5a2c26f3b..95b07c4845 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs @@ -1,11 +1,11 @@ using Microsoft.AspNetCore.Authorization; -using Volo.Abp; using Microsoft.AspNetCore.Authorization.Infrastructure; using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.Flex; using Unity.Flex.Scoresheets; using Unity.Flex.Scoresheets.Enums; @@ -17,7 +17,9 @@ using Unity.GrantManager.Permissions; using Unity.GrantManager.Workflow; using Unity.Modules.Shared; +using Volo.Abp; using Volo.Abp.Application.Services; +using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus.Local; @@ -94,7 +96,7 @@ public async Task GetDisplayList(Guid applicationId) // If AI Scoring feature is disabled, or user doesn't have permissions to view AI assessments, filter out AI assessments from the list var aiScoringEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.Scoring"); - var canViewAI = await AuthorizationService.IsGrantedAsync(GrantApplicationPermissions.AI.ScoringAssistant.Default); + var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.Default.ScoringAssistant.Default); assessmentList = assessmentList .Where(a => !a.IsAiAssessment || (aiScoringEnabled && canViewAI)) .OrderByDescending(a => a.IsAiAssessment) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj index ff57bfd948..081068b468 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Unity.GrantManager.Application.csproj @@ -12,6 +12,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json index 85143a12dd..00e7708474 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Localization/GrantManager/en.json @@ -153,11 +153,6 @@ "Permission:GrantManagerManagement.ApplicationForms.Default": "Manage Forms", "Permission:GrantApplicationManagement.Approvals.BulkApplicationApproval": "Bulk Application Approval", "Permission:GrantApplicationManagement.AIReporting.Default": "AI Reporting", - "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", "ApplicationForms": "Forms", "ApplicationForms:Description": "Description", diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs index 171c99656a..73afc90266 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain.Shared/Permissions/GrantApplicationPermissions.cs @@ -41,31 +41,7 @@ public static class Applicants public const string ApplicantInfoDefault = Default + ".ApplicantInfo"; public const string EditRedStop = ApplicantInfoDefault + ".EditRedStop"; } - - public static class AI - { - public const string GroupName = "AI"; - - public static class Reporting - { - public const string Default = GroupName + ".Reporting"; - } - - public static class ApplicationAnalysis - { - public const string Default = GroupName + ".ApplicationAnalysis"; - } - - public static class AttachmentSummary - { - public const string Default = GroupName + ".AttachmentSummary"; - } - - public static class ScoringAssistant - { - public const string Default = GroupName + ".ScoringAssistant"; - } - } + public static class Assignments { diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs index f881f05e38..6233d6ca6e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs @@ -1,14 +1,15 @@ -using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.Localization; using Unity.GrantManager.Permissions; using Unity.Identity.Web.Navigation; using Unity.Modules.Shared.Permissions; using Unity.TenantManagement; using Unity.TenantManagement.Web.Navigation; +using Volo.Abp.Features; using Volo.Abp.Identity; using Volo.Abp.UI.Navigation; -using Microsoft.Extensions.DependencyInjection; -using Volo.Abp.Features; namespace Unity.GrantManager.Web.Menus; @@ -124,7 +125,7 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex l["Menu:AIReporting"], "~/AIReporting", icon: "fl fl-view-dashboard", - requiredPermissionName: GrantApplicationPermissions.AI.Reporting.Default, + requiredPermissionName: AIPermissions.Default.Reporting.Default, order: 9 ) ); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index f4894efe2d..d3a22c44b2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -1,6 +1,7 @@ @page "{@Model.ApplicationFormSubmissionId?}" @* #pragma warning disable S1128 *@ @using Microsoft.Extensions.Localization +@using Unity.AI.Permissions @using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget @using Unity.GrantManager.Flex @using Unity.GrantManager.Localization @@ -31,7 +32,7 @@ var notificationsFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Notifications"); var readEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email"); var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") - && await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.AI.ApplicationAnalysis.Default); + && await PermissionChecker.IsGrantedAsync(AIPermissions.Default.ApplicationAnalysis.Default); var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); } @section styles diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 57d32cff70..001cf10a62 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -7,6 +7,7 @@ using Volo.Abp.Features; using Volo.Abp.Authorization.Permissions; using Unity.GrantManager.Permissions; +using Unity.AI.Permissions; namespace Unity.GrantManager.Web.Views.Shared.Components.ChefsAttachments { @@ -29,7 +30,7 @@ public async Task InvokeAsync() { var isAIAttachmentSummariesEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") && - await _permissionChecker.IsGrantedAsync(GrantApplicationPermissions.AI.AttachmentSummary.Default); + await _permissionChecker.IsGrantedAsync(AIPermissions.Default.AttachmentSummary.Default); ViewBag.IsAIAttachmentSummariesEnabled = isAIAttachmentSummariesEnabled; return View(); } From 3103c87f8fdbf291200e95ae38c7be88b2eff3ac Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 6 Mar 2026 14:05:31 -0800 Subject: [PATCH 005/245] feature/AB#32216-BuildAIBase-Permission:SonarFixes --- .../Unity.GrantManager/Unity.GrantManager.sln | 4 +- .../AIPermissionDefinitionProvider.cs | 8 ++-- .../Permissions/AIPermissions.cs | 44 +++++++++---------- .../AIDbContextModelCreatingExtensions.cs | 8 +--- .../Unity.AI.Domain.Shared/AIErrorCodes.cs | 7 --- .../modules/Unity.Flex/Unity.Flex.sln | 2 +- .../Unity.Notifications.sln | 2 +- .../modules/Unity.Payments/Unity.Payments.sln | 2 +- .../Unity.Reporting/Unity.Reporting.sln | 2 +- .../Assessments/AssessmentAppService.cs | 4 +- .../Menus/GrantManagerMenuContributor.cs | 2 +- .../Pages/GrantApplications/Details.cshtml | 2 +- .../ChefsAttachments/ChefsAttachments.cs | 2 +- 13 files changed, 36 insertions(+), 53 deletions(-) delete mode 100644 applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AIErrorCodes.cs diff --git a/applications/Unity.GrantManager/Unity.GrantManager.sln b/applications/Unity.GrantManager/Unity.GrantManager.sln index 72ef37a99f..9fa0d5294b 100644 --- a/applications/Unity.GrantManager/Unity.GrantManager.sln +++ b/applications/Unity.GrantManager/Unity.GrantManager.sln @@ -153,7 +153,7 @@ 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 @@ -163,7 +163,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Unity.AI.Application.Contra 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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Unity.AI.Web", "modules\Unity.AI\src\Unity.AI.Web\Unity.AI.Web.csproj", "{378A4EB8-3DC1-420E-98B5-798DE71BEF0D}" +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 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 index 8c56c25ffc..05a8f98c81 100644 --- 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 @@ -16,22 +16,22 @@ public override void Define(IPermissionDefinitionContext context) aiPermissionsGroup.AddPermission( - AIPermissions.Default.Reporting.Default, + AIPermissions.Reporting.ReportingDefault, L("Permission:AI.Reporting")) .RequireFeatures("Unity.AIReporting"); aiPermissionsGroup.AddPermission( - AIPermissions.Default.ApplicationAnalysis.Default, + AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault, L("Permission:AI.ApplicationAnalysis")) .RequireFeatures("Unity.AI.ApplicationAnalysis"); aiPermissionsGroup.AddPermission( - AIPermissions.Default.AttachmentSummary.Default , + AIPermissions.AttachmentSummary.AttachmentSummaryDefault , L("Permission:AI.AttachmentSummary")) .RequireFeatures("Unity.AI.AttachmentSummaries"); aiPermissionsGroup.AddPermission( - AIPermissions.Default.ScoringAssistant.Default, + AIPermissions.ScoringAssistant.ScoringAssistantDefault, L("Permission:AI.ScoringAssistant")) .RequireFeatures("Unity.AI.Scoring"); 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 index 8e94d7fe77..844a8d8e1f 100644 --- 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 @@ -1,5 +1,4 @@ using Volo.Abp.Reflection; -using Volo.Abp.Features; namespace Unity.AI.Permissions; @@ -7,32 +6,29 @@ public static class AIPermissions { public const string GroupName = "AI"; - public static class Default + 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 Management = GroupName + ".Management"; - public const string GroupName = "AI"; - - public static class Reporting - { - public const string Default = GroupName + ".Reporting"; - } - - public static class ApplicationAnalysis - { - public const string Default = GroupName + ".ApplicationAnalysis"; - } - - public static class AttachmentSummary - { - public const string Default = GroupName + ".AttachmentSummary"; - } - - public static class ScoringAssistant - { - public const string Default = GroupName + ".ScoringAssistant"; - } + public const string AttachmentSummaryDefault = GroupName + ".AttachmentSummary"; } + public static class ScoringAssistant + { + public const string ScoringAssistantDefault = GroupName + ".ScoringAssistant"; + } + + public static string[] GetAll() { return ReflectionHelper.GetPublicConstantsRecursively(typeof(AIPermissions)); 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 index 60b243de5d..bfdb1bb031 100644 --- 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 @@ -10,11 +10,7 @@ public static void ConfigureAI(this ModelBuilder modelBuilder) Check.NotNull(modelBuilder, nameof(modelBuilder)); // Configure AI entities here as they are introduced. - // Example: - // modelBuilder.Entity(b => - // { - // b.ToTable(AIDbProperties.DbTablePrefix + "SomeEntities", AIDbProperties.DbSchema); - // b.ConfigureByConvention(); - // }); + // Example: modelBuilder add Entity To table and configurations + } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AIErrorCodes.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AIErrorCodes.cs deleted file mode 100644 index 4dee945346..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/AIErrorCodes.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Unity.AI; - -public static class AIErrorCodes -{ - // Define module error codes here - // Example: public const string SomeError = "AI:00001"; -} 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.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.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.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/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs index 95b07c4845..4cbc3f3ff4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Assessments/AssessmentAppService.cs @@ -14,12 +14,10 @@ using Unity.GrantManager.Applications; using Unity.GrantManager.Comments; using Unity.GrantManager.Exceptions; -using Unity.GrantManager.Permissions; using Unity.GrantManager.Workflow; using Unity.Modules.Shared; using Volo.Abp; using Volo.Abp.Application.Services; -using Volo.Abp.Authorization.Permissions; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; using Volo.Abp.EventBus.Local; @@ -96,7 +94,7 @@ public async Task GetDisplayList(Guid applicationId) // If AI Scoring feature is disabled, or user doesn't have permissions to view AI assessments, filter out AI assessments from the list var aiScoringEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.Scoring"); - var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.Default.ScoringAssistant.Default); + var canViewAI = await AuthorizationService.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault); assessmentList = assessmentList .Where(a => !a.IsAiAssessment || (aiScoringEnabled && canViewAI)) .OrderByDescending(a => a.IsAiAssessment) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs index 6233d6ca6e..f5fe33bf96 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Menus/GrantManagerMenuContributor.cs @@ -125,7 +125,7 @@ private async static Task ConfigureMainMenuAsync(MenuConfigurationContext contex l["Menu:AIReporting"], "~/AIReporting", icon: "fl fl-view-dashboard", - requiredPermissionName: AIPermissions.Default.Reporting.Default, + requiredPermissionName: AIPermissions.Reporting.ReportingDefault, order: 9 ) ); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index d3a22c44b2..96441a8cce 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -32,7 +32,7 @@ var notificationsFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Notifications"); var readEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email"); var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") - && await PermissionChecker.IsGrantedAsync(AIPermissions.Default.ApplicationAnalysis.Default); + && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault); var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); } @section styles diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 001cf10a62..066f5cc80a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -30,7 +30,7 @@ public async Task InvokeAsync() { var isAIAttachmentSummariesEnabled = await _featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") && - await _permissionChecker.IsGrantedAsync(AIPermissions.Default.AttachmentSummary.Default); + await _permissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); ViewBag.IsAIAttachmentSummariesEnabled = isAIAttachmentSummariesEnabled; return View(); } From 931be943c58989c82831246c446e2d779c992e7f Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 6 Mar 2026 14:57:59 -0800 Subject: [PATCH 006/245] feature/AB#32216-BuildAIBase-Permission:SonarFixes --- .../Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json | 1 - .../Configuration/FieldsProviders/ScoresheetFieldsProvider.cs | 2 +- .../GrantApplicationPermissionDefinitionProvider.cs | 1 - .../Pages/GrantApplications/Details.cshtml | 1 - .../Shared/Components/ChefsAttachments/ChefsAttachments.cs | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) 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 index 775b9a4d87..f660d259d3 100644 --- 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 @@ -2,7 +2,6 @@ "culture": "en", "texts": { "Permission:AI": "AI", - "Permission:AI.Default": "AI Management", "Permission:AI.Reporting": "AI Reporting", "Permission:AI.ApplicationAnalysis": "AI Application Analysis", "Permission:AI.AttachmentSummary": "AI Attachment Summary", 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/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Permissions/GrantApplications/GrantApplicationPermissionDefinitionProvider.cs index d21954da46..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; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml index 96441a8cce..f00fa21704 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.cshtml @@ -5,7 +5,6 @@ @using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget @using Unity.GrantManager.Flex @using Unity.GrantManager.Localization -@using Unity.GrantManager.Permissions @using Unity.GrantManager.Web.Pages.GrantApplications @using Unity.GrantManager.Web.Views.Shared.Components.CustomTabWidget @using Unity.GrantManager.Web.Views.Shared.Components.DetailsActionBar diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs index 066f5cc80a..530a0338fd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using Volo.Abp.Features; using Volo.Abp.Authorization.Permissions; -using Unity.GrantManager.Permissions; using Unity.AI.Permissions; namespace Unity.GrantManager.Web.Views.Shared.Components.ChefsAttachments From 4ab223f12c4e715ddd906204e1bd047b5af15b72 Mon Sep 17 00:00:00 2001 From: JamesPasta Date: Fri, 6 Mar 2026 15:24:36 -0800 Subject: [PATCH 007/245] feature/AB#32216-BuildAIBase-Permission:SonarFixes --- .../modules/Unity.AI/src/Unity.AI.Web/Unity.AI.Web.csproj | 1 - 1 file changed, 1 deletion(-) 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 index b1233b386d..79de5268e6 100644 --- 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 @@ -8,7 +8,6 @@ true Library Unity.AI.Web - true From b04db3e6342397bccaa55e9737309c625eecca66 Mon Sep 17 00:00:00 2001 From: Velang Date: Fri, 6 Mar 2026 15:38:31 -0800 Subject: [PATCH 008/245] Dynamic submission id --- .../cypress/pages/ApplicationDetailsPage.ts | 35 +++- .../cypress/regression/ApprovalFlow.cy.ts | 106 ++++++++-- .../Unity.AutoUI/cypress/support/commands.ts | 195 ++++++++++++++++++ .../Unity.AutoUI/cypress/support/index.d.ts | 116 +++++++++-- 4 files changed, 417 insertions(+), 35 deletions(-) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 865a4cb55d..32accb1e40 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -473,10 +473,24 @@ export class ApplicationDetailsPage extends BasePage { } /** - * Click Approve action + * Click Approve action. + * If "Complete Assessment" is enabled in the dropdown, click it first, + * then reopen the dropdown before clicking Approve. */ clickApprove(): this { this.openStatusActionsDropdown(); + cy.get(this.statusActions.completeAssessment).then(($btn) => { + if (!$btn.is(":disabled")) { + cy.wrap($btn).click({ force: true }); + cy.get(this.confirmModal.modal, { timeout: 10000 }).then(($modal) => { + if ($modal.is(":visible")) { + cy.wrap($modal).find(this.confirmModal.confirmButton).click({ force: true }); + cy.wait(1000); + } + }); + this.openStatusActionsDropdown(); + } + }); this.clickElement(this.statusActions.approve); return this; } @@ -553,6 +567,25 @@ export class ApplicationDetailsPage extends BasePage { 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) => { + // Check if SweetAlert2 modal with error icon exists + if ($body.find(".swal2-container").length > 0) { + // Click OK/Confirm button to dismiss + cy.get(".swal2-container") + .find(".swal2-confirm, button:contains('Ok'), button:contains('OK')") + .first() + .click({ force: true }); + cy.wait(500); + } + }); + return this; + } + /** * Verify status action is enabled */ diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index afe0b3ff38..8e6ee158f3 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -1,14 +1,18 @@ /// /** - * Sample Regression Test - Full Approval Workflow + * Approval Flow Regression Test - Full Approval Workflow * * This test validates the complete application approval workflow including: + * - Dynamic submission ID fetching from API * - Searching and opening a submission * - Review and assessment process * - Payment info configuration * - Adding comments and attachments * - Approval action (cancelled for test purposes) + * + * The submission ID is fetched dynamically from the API after login, + * ensuring tests always run against valid, available data. */ import { ApplicationsListPage } from "../pages/ApplicationsListPage"; @@ -18,28 +22,63 @@ import { ApplicationDetailsRightTabPage } from "../pages/ApplicationDetailsRight import { loginIfNeeded } from "../support/auth"; // ============ Test Configuration ============ -// These values can be modified to test different submissions +// Set submissionId to null for dynamic fetch, or provide a value to override const TEST_CONFIG = { - submissionId: "84A888BD", + // Dynamic submission: set to null to fetch from API, or provide ID to use static value + submissionId: null as string | null, grantProgram: "Default Grants Program", approvedAmount: "5000", - supplierNumber: "2002712", + supplierNumber: Cypress.env("environment") === "TEST" ? "2002712" : "2009366", paymentGroup: "Cheque" as const, testComment: "Test comment from automated regression test", + // Options for dynamic submission fetching (only used when submissionId is null) + // Results are sorted by submissionDate descending (latest first) by default + fetchOptions: { + // Filter by category (required for this test) + categoryFilter: "Data Seeder", + // Filter by status (uncomment to enable): + // Available: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' + statusFilter: ["Submitted"], + // Limit to submissions within N days (uncomment to enable): + maxAge: 30, + // Which submission to use after sorting (0 = latest, 1 = second-latest, etc.) + // Use index > 0 to avoid picking the same submission as other concurrent tests + index: 2, + }, }; -describe("Sample Regression Test", () => { +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(); + // Dynamic submission ID - populated after login + let submissionId: string; + before(() => { Cypress.config("includeShadowDom", true); loginIfNeeded(); }); + // ============ Dynamic Submission Fetch ============ + + it("Fetch submission ID from API", () => { + // Use static ID if provided, otherwise fetch dynamically + if (TEST_CONFIG.submissionId) { + submissionId = TEST_CONFIG.submissionId; + cy.log(`📌 Using static submission ID: ${submissionId}`); + return; + } + + // Fetch submission ID dynamically from API using session cookies + cy.fetchDynamicSubmission(TEST_CONFIG.fetchOptions).then((id) => { + submissionId = id; + cy.log(`✅ Fetched dynamic submission ID: ${submissionId}`); + }); + }); + // ============ Navigation & Search ============ it("Switch to grant program", () => { @@ -47,24 +86,22 @@ describe("Sample Regression Test", () => { }); it("Search for submission", () => { + // Ensure submissionId is available before searching + expect(submissionId, "Submission ID should be set").to.exist; listPage .selectQuickDateRange("alltime") .waitForTableRefresh() - .searchForSubmission(TEST_CONFIG.submissionId); + .searchForSubmission(submissionId); }); it("Select submission and open details", () => { - listPage - .selectRowByText(TEST_CONFIG.submissionId) - .clickOpenButton(); + listPage.selectRowByText(submissionId).clickOpenButton(); }); // ============ Review & Assessment ============ it("Navigate to Review and Assessment tab", () => { - detailsPage - .goToReviewAssessmentTab() - .verifyActiveTab("reviewAssessment"); + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); }); it("Enter approval details and save", () => { @@ -76,15 +113,39 @@ describe("Sample Regression Test", () => { }); it("Create and complete assessment", () => { - reviewPage - .scrollToAssessmentList() - .clickCreateAssessment() - .clickCompleteAssessment(); + // Wait for assessment section to load + cy.wait(2000); + reviewPage.scrollToAssessmentList(); + + // Check if Create button exists and click it + 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"); + } + }); + + // Check if Complete button exists and click it + 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", () => { + // Reload page to get fresh data and avoid concurrency issues + cy.reload(); + cy.wait(2000); detailsPage .goToPaymentInfoTab() .enterSupplierNumber(TEST_CONFIG.supplierNumber) @@ -105,6 +166,8 @@ describe("Sample Regression Test", () => { // ============ Comments & Attachments ============ it("Add a comment", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); rightTabPage .goToCommentsTab() .addComment(TEST_CONFIG.testComment) @@ -112,6 +175,8 @@ describe("Sample Regression Test", () => { }); it("Add an attachment", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); rightTabPage.goToAttachmentsTab(); cy.wait(1000); // Allow tab content to load @@ -144,11 +209,10 @@ describe("Sample Regression Test", () => { // ============ Approval Action ============ - it("Test approval workflow (cancel)", () => { - detailsPage - .clickApprove() - .waitForConfirmModal() - .clickCancel(); + it("Test approval workflow (confirm)", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); + detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); }); // ============ Cleanup ============ diff --git a/applications/Unity.AutoUI/cypress/support/commands.ts b/applications/Unity.AutoUI/cypress/support/commands.ts index 686e928f52..62a9858c34 100644 --- a/applications/Unity.AutoUI/cypress/support/commands.ts +++ b/applications/Unity.AutoUI/cypress/support/commands.ts @@ -196,3 +196,198 @@ 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' + */ +Cypress.Commands.add( + "fetchDynamicSubmission", + (options: FetchSubmissionOptions = {}) => { + const baseUrl = Cypress.env("webapp.url"); + const apiUrl = `${baseUrl}api/app/grant-application`; + + // Get XSRF token from cookies for the request + return cy.getCookie("XSRF-TOKEN").then((xsrfCookie) => { + const xsrfToken = xsrfCookie?.value || ""; + + 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: xsrfToken, + }, + 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; + let applications = data.items || []; + + 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", () => { + const baseUrl = Cypress.env("webapp.url"); + const apiUrl = `${baseUrl}api/app/grant-application`; + + return cy.getCookie("XSRF-TOKEN").then((xsrfCookie) => { + const xsrfToken = xsrfCookie?.value || ""; + + 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: xsrfToken, + }, + 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} total applications` }); + return data.items || []; + }); + }); +}); 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 +} From 9667a1058e97091b6320845a94ecd0542715247a Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 6 Mar 2026 16:11:12 -0800 Subject: [PATCH 009/245] AB#32009 Add prompt baseline snapshots for v0 and v1 comparison --- .../Baselines/v0/AnalysisPrompts.v1.txt | 58 + .../Baselines/v0/AttachmentPrompts.v1.txt | 29 + .../Baselines/v0/OpenAIService.v0.cs.txt | 406 ++++++ .../Baselines/v0/OpenAIService.v1.cs.txt | 1134 +++++++++++++++++ .../Baselines/v0/PromptCoreRules.v1.txt | 13 + .../Prompts/Baselines/v0/PromptHeader.v1.txt | 14 + .../Baselines/v0/ScoresheetPrompts.v1.txt | 85 ++ 7 files changed, 1739 insertions(+) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.v1.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.v1.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v0.cs.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.v1.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptHeader.v1.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/ScoresheetPrompts.v1.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.v1.txt new file mode 100644 index 0000000000..d267a1216e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.v1.txt @@ -0,0 +1,58 @@ +namespace Unity.GrantManager.AI +{ + internal static class AnalysisPrompts + { + public const string DefaultRubric = @"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."; + + 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 OutputTemplate = @"{ + ""rating"": """", + ""errors"": [ + { + ""title"": """", + ""detail"": """" + } + ], + ""warnings"": [ + { + ""title"": """", + ""detail"": """" + } + ], + ""summaries"": [ + { + ""title"": """", + ""detail"": """" + } + ] +}"; + + public const string Rules = PromptCoreRules.UseProvidedEvidence + "\n" + + "- Do not invent fields, documents, requirements, or facts.\n" + + @"- Treat missing or empty values as findings only when they weaken rubric evidence. +- Prefer material issues; avoid nitpicking. +- Use 3-6 words for title. +- Each detail must be 1-2 complete sentences. +- Each detail must cite concrete evidence from DATA or ATTACHMENTS. +- If ATTACHMENTS evidence is used, cite the attachment by name in detail. +- If no findings exist, return empty arrays. +- Rating must be HIGH, MEDIUM, or LOW. +" + + PromptCoreRules.MinimumNarrativeWords + "\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, SCORE, OUTPUT, and RULES, return review findings."); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.v1.txt new file mode 100644 index 0000000000..a61cc50848 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.v1.txt @@ -0,0 +1,29 @@ +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 +{ + ""summary"": """" +}"; + + public const string RulesSection = "- Use only ATTACHMENT as evidence.\n" + + "- If ATTACHMENT.text is present, summarize actual content.\n" + + "- If ATTACHMENT.text is null or empty, provide a conservative file-level summary.\n" + + PromptCoreRules.NoInvention + "\n" + + @"- Write 1-2 complete sentences. +- Summary must be grounded in concrete ATTACHMENT evidence. +- Return exactly one object with only the key: summary. +" + + PromptCoreRules.MinimumNarrativeWords + "\n" + + PromptCoreRules.ExactOutputShape + "\n" + + PromptCoreRules.NoExtraOutputKeys + "\n" + + PromptCoreRules.ValidJsonOnly + "\n" + + PromptCoreRules.PlainJsonOnly; + } +} + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v0.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v0.cs.txt new file mode 100644 index 0000000000..239db10623 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v0.cs.txt @@ -0,0 +1,406 @@ +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 string? ApiKey => _configuration["AI:OpenAI:ApiKey"]; + private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; + private readonly string NoKeyError = "OpenAI API key is not configured"; + + public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) + { + _httpClient = httpClient; + _configuration = configuration; + _logger = logger; + _textExtractionService = textExtractionService; + } + + public Task IsAvailableAsync() + { + if (string.IsNullOrEmpty(ApiKey)) + { + _logger.LogWarning("Error: {Message}", NoKeyError); + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + public async Task GenerateSummaryAsync(string content, string? prompt = null, int maxTokens = 150) + { + if (string.IsNullOrEmpty(ApiKey)) + { + _logger.LogWarning("Error: {Message}", NoKeyError); + return "AI analysis not available - service not configured."; + } + + _logger.LogDebug("Calling OpenAI with prompt: {Prompt}", content); + + try + { + var systemPrompt = prompt ?? "You are a professional grant analyst for the BC Government."; + + var requestBody = new + { + messages = new[] + { + new { role = "system", content = systemPrompt }, + new { role = "user", content = content } + }, + 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("Response: {Response}", responseContent); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); + return "AI analysis failed - service temporarily unavailable."; + } + + 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() ?? "No summary generated."; + } + + return "No summary generated."; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating AI summary"); + return "AI analysis failed - please try again later."; + } + } + + public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] fileContent, string contentType) + { + try + { + var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); + + string contentToAnalyze; + string prompt; + + if (!string.IsNullOrWhiteSpace(extractedText)) + { + _logger.LogDebug("Extracted {TextLength} characters from {FileName}", extractedText.Length, fileName); + + contentToAnalyze = $"Document: {fileName}\nType: {contentType}\nContent:\n{extractedText}"; + prompt = "Please analyze this document and provide a concise summary of its content, purpose, and key information, for use by your fellow grant analysts. It should be 1-2 sentences long and about 46 tokens."; + } + else + { + _logger.LogDebug("No text extracted from {FileName}, analyzing metadata only", fileName); + + contentToAnalyze = $"File: {fileName}, Type: {contentType}, Size: {fileContent.Length} bytes"; + prompt = "Please analyze this document and provide a concise summary of its content, purpose, and key information, for use by your fellow grant analysts. It should be 1-2 sentences long and about 46 tokens."; + } + + return await GenerateSummaryAsync(contentToAnalyze, prompt, 150); + } + 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 AnalyzeApplicationAsync(string applicationContent, List attachmentSummaries, string rubric, string? formFieldConfiguration = null) + { + if (string.IsNullOrEmpty(ApiKey)) + { + _logger.LogWarning("{Message}", NoKeyError); + return "AI analysis not available - service not configured."; + } + + try + { + var attachmentSummariesText = attachmentSummaries?.Count > 0 + ? string.Join("\n- ", attachmentSummaries.Select((s, i) => $"Attachment {i + 1}: {s}")) + : "No attachments provided."; + + var fieldConfigurationSection = !string.IsNullOrEmpty(formFieldConfiguration) + ? $@" +{formFieldConfiguration}" + : string.Empty; + + var analysisContent = $@"APPLICATION CONTENT: +{applicationContent} + +ATTACHMENT SUMMARIES: +- {attachmentSummariesText} +{fieldConfigurationSection} + +EVALUATION RUBRIC: +{rubric} + +Analyze this grant application comprehensively across all five rubric categories (Eligibility, Completeness, Financial Review, Risk Assessment, and Quality Indicators). Identify issues, concerns, and areas for improvement. Return your findings in the following JSON format: +{{ + ""overall_score"": ""HIGH/MEDIUM/LOW"", + ""warnings"": [ + {{ + ""category"": ""Brief summary of the warning"", + ""message"": ""Detailed warning message with full context and explanation"", + ""severity"": ""WARNING"" + }} + ], + ""errors"": [ + {{ + ""category"": ""Brief summary of the error"", + ""message"": ""Detailed error message with full context and explanation"", + ""severity"": ""ERROR"" + }} + ], + ""recommendations"": [ + {{ + ""category"": ""Brief summary of the recommendation"", + ""message"": ""Detailed recommendation with specific actionable guidance"" + }} + ] +}} + +Important: The 'category' field should be a concise summary (3-6 words) that captures the essence of the issue, while the 'message' field should contain the detailed explanation."; + + var systemPrompt = @"You are an expert grant application reviewer for the BC Government. + +Conduct a thorough, comprehensive analysis across all rubric categories. Identify substantive issues, concerns, and opportunities for improvement. + +Classify findings based on their impact on the application's evaluation and fundability: +- ERRORS: Important missing information, significant gaps in required content, compliance issues, or major concerns affecting eligibility +- WARNINGS: Areas needing clarification, moderate issues, or concerns that should be addressed + +Evaluate the quality, clarity, and appropriateness of all application content. Be thorough but fair - identify real issues while avoiding nitpicking. + +Respond only with valid JSON in the exact format requested."; + + var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); + + // 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 "AI analysis failed - please try again later."; + } + } + + 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()) + { + if (property.Name == "errors" || property.Name == "warnings") + { + writer.WritePropertyName(property.Name); + 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 + { + property.WriteTo(writer); + } + } + + // Add dismissed_items array if not present + if (!jsonDoc.RootElement.TryGetProperty("dismissed_items", out _)) + { + writer.WritePropertyName("dismissed_items"); + 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}", NoKeyError); + 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."; + + return await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + } + 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}", NoKeyError); + 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 SECTION: {sectionName} +{sectionJson} + +Please analyze this grant application and provide appropriate answers for each question in the ""{sectionName}"" section only. + +For each question, provide: +1. Your answer based on the application content +2. A brief cited description (1-2 sentences) explaining your reasoning with specific references to the application content +3. A confidence score from 0-100 indicating how confident you are in your answer based on available information + +Guidelines for answers: +- For numeric questions, provide a numeric value within the specified range +- For yes/no questions, provide either 'Yes' or 'No' +- For text questions, provide a concise, relevant response +- For select list questions, respond with ONLY the number from the 'number' field (1, 2, 3, etc.) of your chosen option. NEVER return 0 - the lowest valid answer is 1. For example: if you want '(0 pts) No outcomes provided', choose the option where number=1, not 0. +- For text area questions, provide a detailed but concise response +- Base your confidence score on how clearly the application content supports your answer + +Return your response as a JSON object where each key is the question ID and the value contains the answer, citation, and confidence: +{{ + ""question-id-1"": {{ + ""answer"": ""your-answer-here"", + ""citation"": ""Brief explanation with specific reference to application content"", + ""confidence"": 85 + }}, + ""question-id-2"": {{ + ""answer"": ""3"", + ""citation"": ""Based on the project budget of $50,000 mentioned in the application, this falls into the medium budget category"", + ""confidence"": 90 + }} +}} + +IMPORTANT FOR SELECT LIST QUESTIONS: If a question has availableOptions like: +[{{""number"":1,""value"":""Low (Under $25K)""}}, {{""number"":2,""value"":""Medium ($25K-$75K)""}}, {{""number"":3,""value"":""High (Over $75K)""}}] +Then respond with ONLY the number (e.g., ""3"" for ""High (Over $75K)""), not the text value. + +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 section questions based on the application content. +Be thorough, objective, and fair in your assessment. Base your answers strictly on the provided application content. +Always provide citations that reference specific parts of the application content to support your answers. +Be honest about your confidence level - if information is missing or unclear, reflect this in a lower confidence score. +Respond only with valid JSON in the exact format requested."; + + return await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", sectionName); + return "{}"; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt new file mode 100644 index 0000000000..e421036ebd --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt @@ -0,0 +1,1134 @@ +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.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +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 readonly JsonSerializerOptions _prettyJsonOptions = new() { WriteIndented = true }; + + private string? ApiKey => _configuration["AI:OpenAI:ApiKey"]; + private bool HasApiKey => !string.IsNullOrWhiteSpace(ApiKey); + private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; + private bool LogPayloads => _configuration.GetValue("AI:Logging:LogPayloads") ?? false; + private readonly string NoApiKeyMessage = "OpenAI API key is not configured"; + private const string ServiceNotConfiguredMessage = "AI analysis not available - service not configured."; + private const string ServiceTemporarilyUnavailableMessage = "AI analysis failed - service temporarily unavailable."; + private const string GenericFailureMessage = "AI analysis failed - please try again later."; + private const string NoSummaryGeneratedMessage = "No summary generated."; + private const string EmptyJsonObject = "{}"; + private const int ScoresheetAllMaxTokens = 2000; + private const int ScoresheetSectionMaxTokens = 3200; + private const int AnalysisMaxTokens = 1600; + private const string DefaultContentType = "application/octet-stream"; + private const int AttachmentSummaryMaxTokens = 240; + private const string AttachmentSummaryUnavailableMessage = "AI analysis not available for this attachment"; + private const string ScoreHigh = "HIGH"; + private const string ScoreMedium = "MEDIUM"; + private const string ScoreLow = "LOW"; + private const string AiPromptLogRelativePath = "logs/ai-prompts.log"; + private static int _aiPromptLogInitialized; + + public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) + { + _httpClient = httpClient; + _configuration = configuration; + _logger = logger; + _textExtractionService = textExtractionService; + } + + public Task IsAvailableAsync() + { + if (!HasApiKey) + { + _logger.LogWarning("Error: {Message}", NoApiKeyMessage); + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + public async Task GenerateCompletionAsync(AICompletionRequest request) + { + if (request == null) + { + _logger.LogWarning("AI completion request was null."); + return GenericFailureMessage; + } + + var userPrompt = request.UserPrompt ?? string.Empty; + var systemPrompt = request.SystemPrompt; + var maxTokens = request.MaxTokens <= 0 ? 150 : request.MaxTokens; + var temperature = request.Temperature ?? 0.3; + + return await ExecuteChatCompletionAsync(userPrompt, systemPrompt, maxTokens, temperature); + } + + private async Task ExecuteChatCompletionAsync( + string userPrompt, + string? systemPrompt = null, + int maxTokens = 150, + double temperature = 0.3) + { + if (!HasApiKey) + { + _logger.LogWarning("Error: {Message}", NoApiKeyMessage); + return ServiceNotConfiguredMessage; + } + + _logger.LogDebug( + "Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", + userPrompt?.Length ?? 0, + maxTokens); + + try + { + string resolvedSystemPrompt = systemPrompt ?? "You are a professional grant analyst for the BC Government."; + + var requestBody = new Dictionary + { + ["messages"] = new[] + { + new { role = "system", content = resolvedSystemPrompt }, + new { role = "user", content = userPrompt ?? string.Empty } + }, + ["max_tokens"] = maxTokens, + ["temperature"] = temperature + }; + + var json = JsonSerializer.Serialize(requestBody); + var authValue = ApiKey!.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) + ? ApiKey.Substring("Bearer ".Length).Trim() + : ApiKey; + + using var request = new HttpRequestMessage(HttpMethod.Post, ApiUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authValue); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.SendAsync(request); + var responseContent = await response.Content.ReadAsStringAsync() ?? string.Empty; + + _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; + } + + using var jsonDoc = JsonDocument.Parse(responseContent ?? string.Empty); + 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 GenericFailureMessage; + } + } + + // Canonical attachment summary prompt contract is defined by: + // AttachmentPrompts.SystemPrompt, AttachmentPrompts.OutputSection, and AttachmentPrompts.RulesSection. + public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) + { + try + { + if (request == null) + { + _logger.LogWarning("Attachment summary request was null."); + return $"{AttachmentSummaryUnavailableMessage} (unknown)."; + } + + var normalizedFileName = string.IsNullOrWhiteSpace(request.FileName) ? "unknown" : request.FileName.Trim(); + var normalizedContentType = string.IsNullOrWhiteSpace(request.ContentType) ? DefaultContentType : request.ContentType.Trim(); + var normalizedFileContent = request.FileContent ?? Array.Empty(); + var extractedText = await _textExtractionService.ExtractTextAsync(normalizedFileName, normalizedFileContent, normalizedContentType); + + var hasExtractedText = !string.IsNullOrWhiteSpace(extractedText); + if (hasExtractedText) + { + _logger.LogDebug("Extracted {TextLength} characters from {FileName}", extractedText!.Length, normalizedFileName); + } + else + { + _logger.LogDebug("No text extracted from {FileName}, analyzing metadata only", normalizedFileName); + } + + var attachmentInput = new AttachmentPromptInput + { + Name = normalizedFileName, + Text = hasExtractedText ? extractedText : null + }; + + var contentToAnalyze = BuildAttachmentSummaryPrompt(attachmentInput); + return await ExecutePromptWithRetryAsync( + promptType: "AttachmentSummary", + systemPrompt: AttachmentPrompts.SystemPrompt, + userPrompt: contentToAnalyze, + maxTokens: AttachmentSummaryMaxTokens, + normalizeResponse: NormalizeAttachmentSummaryResponse, + isValidNormalizedResponse: normalized => !string.IsNullOrWhiteSpace(normalized), + fallbackResponse: string.Empty); + } + catch (Exception ex) + { + var fileName = request?.FileName ?? "unknown"; + _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); + return $"{AttachmentSummaryUnavailableMessage} ({fileName})."; + } + } + + private string BuildAttachmentSummaryPrompt(AttachmentPromptInput attachmentInput) + { + return $@"ATTACHMENT +{JsonSerializer.Serialize(attachmentInput, _prettyJsonOptions)} + +{AttachmentPrompts.OutputSection} + +RULES +{AttachmentPrompts.RulesSection}"; + } + + private string NormalizeAttachmentSummaryResponse(string response) + { + if (!TryParseJsonObjectFromResponse(response, out var responseObject)) + { + return string.Empty; + } + + if (responseObject.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && + summaryProp.ValueKind == JsonValueKind.String) + { + return summaryProp.GetString()?.Trim() ?? string.Empty; + } + + return responseObject.ToString().Trim(); + } + + // Canonical analysis prompt contract is defined by: + // AnalysisPrompts.DefaultRubric, AnalysisPrompts.ScoreRules, AnalysisPrompts.OutputTemplate, + // AnalysisPrompts.Rules, and AnalysisPrompts.SystemPrompt. + public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) + { + if (!HasApiKey) + { + _logger.LogWarning("{Message}", NoApiKeyMessage); + return ServiceNotConfiguredMessage; + } + + try + { + if (request == null) + { + _logger.LogWarning("Application analysis request was null."); + return BuildEmptyAnalysisResponseJson(); + } + + var emptyObject = CreateEmptyJsonObject(); + var schemaPayload = request.Schema.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Schema; + var dataPayload = request.Data.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Data; + + if (schemaPayload.ValueKind != JsonValueKind.Object || dataPayload.ValueKind != JsonValueKind.Object) + { + _logger.LogWarning( + "Invalid application analysis request payload shape. Schema kind: {SchemaKind}, Data kind: {DataKind}.", + schemaPayload.ValueKind, + dataPayload.ValueKind); + return BuildEmptyAnalysisResponseJson(); + } + + var attachmentsPayload = request.Attachments? + .Where(a => a != null && !string.IsNullOrWhiteSpace(a.Summary)) + .Select(a => new ApplicationAnalysisAttachment + { + Name = string.IsNullOrWhiteSpace(a.Name) ? "attachment" : a.Name.Trim(), + Summary = a.Summary.Trim() + }) + .ToList() ?? new List(); + + var rubricText = !string.IsNullOrWhiteSpace(request.Rubric) ? request.Rubric : AnalysisPrompts.DefaultRubric; + var analysisPrompt = BuildAnalysisPrompt(schemaPayload, dataPayload, attachmentsPayload, rubricText); + return await ExecutePromptWithRetryAsync( + promptType: "ApplicationAnalysis", + systemPrompt: AnalysisPrompts.SystemPrompt, + userPrompt: analysisPrompt, + maxTokens: AnalysisMaxTokens, + normalizeResponse: NormalizeAnalysisResponse, + isValidNormalizedResponse: IsValidAnalysisNormalizedResponse, + fallbackResponse: BuildEmptyAnalysisResponseJson()); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error analyzing application"); + return BuildEmptyAnalysisResponseJson(); + } + } + + private string NormalizeAnalysisResponse(string analysisJson) + { + try + { + if (!TryParseJsonObjectFromResponse(analysisJson, out var analysisObject)) + { + _logger.LogError("Invalid analysis JSON response."); + return BuildEmptyAnalysisResponseJson(); + } + + var parseOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + var model = JsonSerializer.Deserialize(analysisObject.GetRawText(), parseOptions); + if (model == null) + { + return BuildEmptyAnalysisResponseJson(); + } + + model.Errors ??= new List(); + model.Warnings ??= new List(); + model.Summaries ??= new List(); + model.Dismissed ??= new List(); + + model.Rating = NormalizeRating(model.Rating); + + foreach (var error in model.Errors) + { + error.Id = string.IsNullOrWhiteSpace(error.Id) ? Guid.NewGuid().ToString() : error.Id; + } + + foreach (var warning in model.Warnings) + { + warning.Id = string.IsNullOrWhiteSpace(warning.Id) ? Guid.NewGuid().ToString() : warning.Id; + } + + model.Dismissed = model.Dismissed + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + var normalizedOutput = new ApplicationAnalysisResponse + { + Rating = model.Rating, + Errors = model.Errors, + Warnings = model.Warnings, + Summaries = model.Summaries, + Dismissed = model.Dismissed + }; + + return JsonSerializer.Serialize(normalizedOutput, _prettyJsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error normalizing analysis response."); + return BuildEmptyAnalysisResponseJson(); + } + } + + private static string NormalizeRating(string? rating) + { + var normalized = rating?.Trim().ToUpperInvariant(); + return normalized switch + { + ScoreHigh => ScoreHigh, + ScoreMedium => ScoreMedium, + ScoreLow => ScoreLow, + _ => ScoreMedium + }; + } + + private string BuildAnalysisPrompt( + JsonElement schemaPayload, + JsonElement dataPayload, + List attachmentsPayload, + string rubricText) + { + var analysisAttachments = (attachmentsPayload ?? new List()) + .Select(a => new AnalysisAttachmentPromptItem + { + Name = a.Name, + Summary = a.Summary + }) + .ToList(); + + return $@"SCHEMA +{JsonSerializer.Serialize(schemaPayload, _prettyJsonOptions)} + +DATA +{JsonSerializer.Serialize(dataPayload, _prettyJsonOptions)} + +ATTACHMENTS +{JsonSerializer.Serialize(analysisAttachments, _prettyJsonOptions)} + +RUBRIC +{rubricText ?? AnalysisPrompts.DefaultRubric} + +SCORE +{AnalysisPrompts.ScoreRules} + +OUTPUT +{AnalysisPrompts.OutputTemplate} + +RULES +{AnalysisPrompts.Rules}"; + } + // Canonical scoresheet-all prompt contract is defined by: + // ScoresheetPrompts.AllSystemPrompt, ScoresheetPrompts.AllOutputTemplate, and ScoresheetPrompts.AllRules. + public async Task GenerateScoresheetAllAnswersAsync(ScoresheetAllRequest request) + { + if (!HasApiKey) + { + _logger.LogWarning("{Message}", NoApiKeyMessage); + return EmptyJsonObject; + } + + try + { + if (request == null) + { + _logger.LogWarning("Scoresheet-all request was null."); + return EmptyJsonObject; + } + + if (!IsValidScoresheetQuestionsPayload(request.Questions)) + { + _logger.LogWarning( + "Invalid scoresheet-all questions payload shape. Questions kind: {QuestionsKind}.", + request.Questions.ValueKind); + return EmptyJsonObject; + } + + var scoresheetPrompt = BuildScoresheetAllPrompt(request); + return await ExecutePromptWithRetryAsync( + promptType: "ScoresheetAll", + systemPrompt: ScoresheetPrompts.AllSystemPrompt, + userPrompt: scoresheetPrompt, + maxTokens: ScoresheetAllMaxTokens, + normalizeResponse: NormalizeScoresheetAllResponse, + isValidNormalizedResponse: normalized => !IsEmptyJsonObject(normalized), + fallbackResponse: EmptyJsonObject); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating scoresheet answers"); + return EmptyJsonObject; + } + } + // Canonical scoresheet-section prompt contract is defined by: + // ScoresheetPrompts.SectionSystemPrompt, ScoresheetPrompts.SectionOutputTemplate, and ScoresheetPrompts.SectionRules. + public async Task GenerateScoresheetSectionAnswersAsync(ScoresheetSectionRequest request) + { + if (!HasApiKey) + { + _logger.LogWarning("{Message}", NoApiKeyMessage); + return EmptyJsonObject; + } + + try + { + if (request == null) + { + _logger.LogWarning("Scoresheet-section request was null."); + return EmptyJsonObject; + } + + if (!IsValidScoresheetSectionSchemaPayload(request.SectionSchema)) + { + _logger.LogWarning( + "Invalid scoresheet-section schema payload shape. SectionSchema kind: {SectionSchemaKind}.", + request.SectionSchema.ValueKind); + return EmptyJsonObject; + } + + var scoresheetSectionPrompt = BuildScoresheetSectionPrompt(request); + return await ExecutePromptWithRetryAsync( + promptType: "ScoresheetSection", + systemPrompt: ScoresheetPrompts.SectionSystemPrompt, + userPrompt: scoresheetSectionPrompt, + maxTokens: ScoresheetSectionMaxTokens, + normalizeResponse: raw => NormalizeScoresheetSectionResponse(raw, request.SectionSchema), + isValidNormalizedResponse: normalized => IsCompleteScoresheetSectionResponse(normalized, request.SectionSchema), + fallbackResponse: EmptyJsonObject); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", request?.SectionName); + return EmptyJsonObject; + } + } + + private string BuildScoresheetAllPrompt(ScoresheetAllRequest request) + { + var emptyObject = CreateEmptyJsonObject(); + var questionsPayload = request.Questions.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Questions; + var dataPayload = request.Data.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Data; + var attachmentsPayload = BuildScoresheetAttachmentPromptItems(request.Attachments); + + return $@"DATA +{JsonSerializer.Serialize(dataPayload, _prettyJsonOptions)} + +ATTACHMENTS +{JsonSerializer.Serialize(attachmentsPayload, _prettyJsonOptions)} + +QUESTIONS +{JsonSerializer.Serialize(questionsPayload, _prettyJsonOptions)} + +OUTPUT +{ScoresheetPrompts.AllOutputTemplate} + +RULES +{ScoresheetPrompts.AllRules}"; + } + + private string BuildScoresheetSectionPrompt(ScoresheetSectionRequest request) + { + var emptyObject = CreateEmptyJsonObject(); + var sectionSchemaPayload = request.SectionSchema.ValueKind == JsonValueKind.Undefined ? emptyObject : request.SectionSchema; + var dataPayload = request.Data.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Data; + var attachmentsPayload = BuildScoresheetAttachmentPromptItems(request.Attachments); + var responseTemplate = BuildScoresheetSectionResponseTemplate(sectionSchemaPayload); + var sectionName = request.SectionName ?? string.Empty; + var section = new + { + name = sectionName, + questions = sectionSchemaPayload + }; + + return $@"DATA +{JsonSerializer.Serialize(dataPayload, _prettyJsonOptions)} + +ATTACHMENTS +{JsonSerializer.Serialize(attachmentsPayload, _prettyJsonOptions)} + +SECTION +{JsonSerializer.Serialize(section, _prettyJsonOptions)} + +RESPONSE +{JsonSerializer.Serialize(responseTemplate, _prettyJsonOptions)} + +OUTPUT +{ScoresheetPrompts.SectionOutputTemplate} + +RULES +{ScoresheetPrompts.SectionRules}"; + } + + private static Dictionary BuildScoresheetSectionResponseTemplate(JsonElement sectionSchemaPayload) + { + var template = new Dictionary(StringComparer.Ordinal); + var questions = EnumerateSectionQuestions(sectionSchemaPayload); + + foreach (var question in questions) + { + if (!TryGetQuestionId(question, out var questionId)) + { + continue; + } + + template[questionId] = new Dictionary + { + [AIJsonKeys.Answer] = string.Empty, + [AIJsonKeys.Rationale] = string.Empty, + [AIJsonKeys.Confidence] = 0 + }; + } + + return template; + } + + private static List BuildScoresheetAttachmentPromptItems(List attachments) + { + return attachments? + .Where(attachment => attachment != null && !string.IsNullOrWhiteSpace(attachment.Summary)) + .Select(attachment => (object)new + { + name = string.IsNullOrWhiteSpace(attachment.Name) ? "attachment" : attachment.Name.Trim(), + summary = attachment.Summary.Trim() + }) + .ToList() ?? new List(); + } + + private static bool IsValidScoresheetQuestionsPayload(JsonElement questions) + { + return questions.ValueKind == JsonValueKind.Object || questions.ValueKind == JsonValueKind.Array; + } + + private static bool IsValidScoresheetSectionSchemaPayload(JsonElement sectionSchema) + { + return sectionSchema.ValueKind == JsonValueKind.Object || sectionSchema.ValueKind == JsonValueKind.Array; + } + + private string BuildEmptyAnalysisResponseJson() + { + var emptyResponse = new ApplicationAnalysisResponse + { + Rating = ScoreMedium, + Errors = new List(), + Warnings = new List(), + Summaries = new List(), + Dismissed = new List() + }; + + return JsonSerializer.Serialize(emptyResponse, _prettyJsonOptions); + } + + private static JsonElement CreateEmptyJsonObject() + { + return JsonSerializer.SerializeToElement(new { }); + } + + private string NormalizeScoresheetAllResponse(string response) + { + if (!TryParseJsonObjectFromResponse(response, out var responseObject)) + { + _logger.LogError("Invalid scoresheet-all JSON response."); + return EmptyJsonObject; + } + + return JsonSerializer.Serialize(responseObject, _prettyJsonOptions); + } + + private string NormalizeScoresheetSectionResponse(string response, JsonElement sectionSchemaPayload) + { + if (!TryParseJsonObjectFromResponse(response, out var responseObject)) + { + _logger.LogError("Invalid scoresheet-section JSON response."); + return EmptyJsonObject; + } + + var questionSpecs = BuildSectionQuestionSpecs(sectionSchemaPayload); + var normalized = new Dictionary(); + IEnumerable questionIds = questionSpecs.Count > 0 + ? questionSpecs.Keys + : responseObject.EnumerateObject().Select(p => p.Name); + + foreach (var questionId in questionIds) + { + responseObject.TryGetProperty(questionId, out var value); + var answer = value.ValueKind == JsonValueKind.Undefined ? string.Empty : value.ToString(); + var rationale = string.Empty; + var confidence = 0; + + if (value.ValueKind == JsonValueKind.Object) + { + if (value.TryGetProperty(AIJsonKeys.Answer, out var answerProp)) + { + answer = answerProp.ToString(); + } + + if (value.TryGetProperty(AIJsonKeys.Rationale, out var rationaleProp)) + { + rationale = rationaleProp.ToString(); + } + + if (value.TryGetProperty(AIJsonKeys.Confidence, out var confidenceProp)) + { + confidence = NormalizeConfidenceIncrement(ParseConfidenceValue(confidenceProp)); + } + } + + questionSpecs.TryGetValue(questionId, out var questionSpec); + var normalizedAnswer = NormalizeAnswerByQuestionType(answer, questionSpec); + var normalizedRationale = rationale?.Trim() ?? string.Empty; + var normalizedConfidence = NormalizeConfidenceIncrement(confidence); + + normalized[questionId] = new Dictionary + { + [AIJsonKeys.Answer] = normalizedAnswer, + [AIJsonKeys.Rationale] = normalizedRationale, + [AIJsonKeys.Confidence] = normalizedConfidence + }; + } + + return JsonSerializer.Serialize(normalized, _prettyJsonOptions); + } + + private static Dictionary BuildSectionQuestionSpecs(JsonElement sectionSchemaPayload) + { + var specs = new Dictionary(StringComparer.Ordinal); + foreach (var question in EnumerateSectionQuestions(sectionSchemaPayload)) + { + if (!TryGetQuestionId(question, out var questionId)) + { + continue; + } + + var spec = new SectionQuestionSpec + { + QuestionType = question.TryGetProperty("type", out var typeProp) + ? typeProp.GetString() ?? string.Empty + : string.Empty + }; + + if (question.TryGetProperty("options", out var options) && options.ValueKind == JsonValueKind.Array) + { + foreach (var option in options.EnumerateArray()) + { + if (!option.TryGetProperty("number", out var numberProp)) + { + continue; + } + + var number = numberProp.ValueKind == JsonValueKind.Number + ? numberProp.GetInt32().ToString() + : numberProp.ToString(); + + if (string.IsNullOrWhiteSpace(number)) + { + continue; + } + + spec.OptionNumbers.Add(number); + var label = option.TryGetProperty("value", out var valueProp) ? valueProp.ToString() : string.Empty; + spec.OptionLabels[number] = label ?? string.Empty; + } + } + + specs[questionId] = spec; + } + + return specs; + } + + private static IEnumerable EnumerateSectionQuestions(JsonElement sectionSchemaPayload) + { + if (sectionSchemaPayload.ValueKind == JsonValueKind.Array) + { + foreach (var question in sectionSchemaPayload.EnumerateArray()) + { + if (question.ValueKind == JsonValueKind.Object) + { + yield return question; + } + } + } + else if (sectionSchemaPayload.ValueKind == JsonValueKind.Object && + sectionSchemaPayload.TryGetProperty("questions", out var questions) && + questions.ValueKind == JsonValueKind.Array) + { + foreach (var question in questions.EnumerateArray()) + { + if (question.ValueKind == JsonValueKind.Object) + { + yield return question; + } + } + } + } + + private static bool TryGetQuestionId(JsonElement question, out string questionId) + { + questionId = string.Empty; + if (!question.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) + { + return false; + } + + questionId = idProp.GetString() ?? string.Empty; + return !string.IsNullOrWhiteSpace(questionId); + } + + private static object NormalizeAnswerByQuestionType(string answer, SectionQuestionSpec? questionSpec) + { + var normalizedAnswer = answer?.Trim() ?? string.Empty; + var questionType = questionSpec?.QuestionType ?? string.Empty; + + if (questionType.Equals("YesNo", StringComparison.OrdinalIgnoreCase)) + { + if (normalizedAnswer.Equals("Yes", StringComparison.OrdinalIgnoreCase)) + { + return "Yes"; + } + + if (normalizedAnswer.Equals("No", StringComparison.OrdinalIgnoreCase)) + { + return "No"; + } + + return "No"; + } + + if (questionType.Equals("Number", StringComparison.OrdinalIgnoreCase)) + { + if (decimal.TryParse(normalizedAnswer, out var decimalAnswer)) + { + return decimalAnswer; + } + + return 0; + } + + if (questionType.Equals("SelectList", StringComparison.OrdinalIgnoreCase)) + { + return NormalizeSelectListAnswer(normalizedAnswer, questionSpec); + } + + if (questionType.Equals("Text", StringComparison.OrdinalIgnoreCase) || + questionType.Equals("TextArea", StringComparison.OrdinalIgnoreCase)) + { + return normalizedAnswer; + } + + return normalizedAnswer; + } + + private static string NormalizeSelectListAnswer(string answer, SectionQuestionSpec? questionSpec) + { + var options = questionSpec?.OptionNumbers ?? new List(); + if (options.Count == 0) + { + return answer; + } + + if (options.Contains(answer)) + { + return answer; + } + + if (int.TryParse(answer, out var parsedAnswer) && options.Contains(parsedAnswer.ToString())) + { + return parsedAnswer.ToString(); + } + + return answer; + } + + private sealed class SectionQuestionSpec + { + public string QuestionType { get; set; } = string.Empty; + public List OptionNumbers { get; set; } = new(); + public Dictionary OptionLabels { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + private static int NormalizeConfidenceIncrement(int confidence) + { + var rounded = (int)Math.Round(confidence / 5.0, MidpointRounding.AwayFromZero) * 5; + return Math.Clamp(rounded, 0, 100); + } + + private static int ParseConfidenceValue(JsonElement confidenceProp) + { + if (confidenceProp.ValueKind == JsonValueKind.Number) + { + if (confidenceProp.TryGetInt32(out var intValue)) + { + return intValue; + } + + if (confidenceProp.TryGetDouble(out var doubleValue)) + { + return (int)Math.Round(doubleValue, MidpointRounding.AwayFromZero); + } + } + + if (confidenceProp.ValueKind == JsonValueKind.String) + { + var raw = confidenceProp.GetString(); + if (int.TryParse(raw, out var parsedInt)) + { + return parsedInt; + } + + if (double.TryParse(raw, out var parsedDouble)) + { + return (int)Math.Round(parsedDouble, MidpointRounding.AwayFromZero); + } + } + + return 0; + } + + private void LogPromptOutput(string promptType, string output) + { + if (!LogPayloads) + { + return; + } + + var formattedOutput = FormatPromptOutputForLog(promptType, output); + _logger.LogDebug( + "AI {PromptType} model output payload: {ModelOutput}", + promptType, + formattedOutput); + WriteAiPromptLog(promptType, "OUTPUT", formattedOutput); + } + + private void LogPromptInput(string promptType, string? systemPrompt, string userPrompt) + { + if (!LogPayloads) + { + return; + } + + var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); + _logger.LogDebug( + "AI {PromptType} input payload: {PromptInput}", + promptType, + formattedInput); + WriteAiPromptLog(promptType, "INPUT", formattedInput); + } + + private void WriteAiPromptLog(string promptType, string payloadType, string payload) + { + if (!LogPayloads) + { + return; + } + + try + { + var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); + var logPath = Path.Combine(AppContext.BaseDirectory, AiPromptLogRelativePath); + EnsureAiPromptLogInitialized(logPath); + + var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; + File.AppendAllText(logPath, entry); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to write AI prompt log file."); + } + } + + private static void EnsureAiPromptLogInitialized(string logPath) + { + var directory = Path.GetDirectoryName(logPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + // Reset once per process run so each fresh app run starts with a clean AI prompt log. + if (Interlocked.Exchange(ref _aiPromptLogInitialized, 1) == 0) + { + File.WriteAllText(logPath, string.Empty); + } + } + + 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 string FormatPromptOutputForLog(string promptType, string output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return string.Empty; + } + + // For JSON contracts, log only normalized payload JSON. + if (TryParseJsonObjectFromResponse(output, out var jsonObject)) + { + return JsonSerializer.Serialize(jsonObject, _prettyJsonOptions); + } + + 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 bool IsEmptyJsonObject(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return true; + } + + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.ValueKind == JsonValueKind.Object && + !doc.RootElement.EnumerateObject().Any(); + } + catch (JsonException) + { + return true; + } + } + + private async Task ExecutePromptWithRetryAsync( + string promptType, + string systemPrompt, + string userPrompt, + int maxTokens, + Func normalizeResponse, + Func isValidNormalizedResponse, + string fallbackResponse, + int maxAttempts = 2) + { + LogPromptInput(promptType, systemPrompt, userPrompt); + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + var rawResponse = await GenerateCompletionAsync(new AICompletionRequest + { + UserPrompt = userPrompt, + SystemPrompt = systemPrompt, + MaxTokens = maxTokens + }); + + var outputType = attempt == 1 ? promptType : $"{promptType}Retry"; + LogPromptOutput(outputType, rawResponse); + + var normalized = normalizeResponse(rawResponse); + if (isValidNormalizedResponse(normalized)) + { + return normalized; + } + + if (attempt < maxAttempts) + { + _logger.LogWarning( + "{PromptType} response failed output-shape validation on attempt {Attempt}/{MaxAttempts}. Retrying.", + promptType, + attempt, + maxAttempts); + } + } + + return fallbackResponse; + } + + private static bool IsValidAnalysisNormalizedResponse(string normalizedJson) + { + if (!TryParseJsonObjectFromResponse(normalizedJson, out var root)) + { + return false; + } + + return root.TryGetProperty("rating", out _) && + root.TryGetProperty("errors", out _) && + root.TryGetProperty("warnings", out _) && + root.TryGetProperty("summaries", out _); + } + + private static bool IsCompleteScoresheetSectionResponse(string normalizedJson, JsonElement sectionSchemaPayload) + { + if (!TryParseJsonObjectFromResponse(normalizedJson, out var root)) + { + return false; + } + + var expectedQuestionIds = EnumerateSectionQuestions(sectionSchemaPayload) + .Select(q => TryGetQuestionId(q, out var id) ? id : string.Empty) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .ToList(); + + if (expectedQuestionIds.Count == 0) + { + return false; + } + + foreach (var questionId in expectedQuestionIds) + { + if (!root.TryGetProperty(questionId, out var answerObject) || answerObject.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerProp) || + string.IsNullOrWhiteSpace(answerProp.ToString())) + { + return false; + } + + if (!answerObject.TryGetProperty(AIJsonKeys.Rationale, out var rationaleProp) || + string.IsNullOrWhiteSpace(rationaleProp.ToString())) + { + return false; + } + + if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out _)) + { + return false; + } + } + + return true; + } + + 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) + { + cleaned = cleaned.Substring(startIndex + 1); + } + } + + if (cleaned.EndsWith("```", StringComparison.Ordinal)) + { + var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); + if (lastIndex > 0) + { + cleaned = cleaned.Substring(0, lastIndex); + } + } + + return cleaned.Trim(); + } + } +} + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.v1.txt new file mode 100644 index 0000000000..e11dce3c97 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.v1.txt @@ -0,0 +1,13 @@ +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/Baselines/v0/PromptHeader.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptHeader.v1.txt new file mode 100644 index 0000000000..701a43e740 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptHeader.v1.txt @@ -0,0 +1,14 @@ +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/Baselines/v0/ScoresheetPrompts.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/ScoresheetPrompts.v1.txt new file mode 100644 index 0000000000..bfe883d643 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/ScoresheetPrompts.v1.txt @@ -0,0 +1,85 @@ +namespace Unity.GrantManager.AI +{ + internal static class ScoresheetPrompts + { + public static readonly string AllSystemPrompt = PromptHeader.Build( + "You are an expert grant application reviewer for the BC Government.", + "Using DATA, ATTACHMENTS, QUESTIONS, OUTPUT, and RULES, provide answers for all scoresheet questions."); + + public const string AllOutputTemplate = @"{ + """": """" +}"; + + public const string AllRules = "- Use only DATA and ATTACHMENTS as evidence.\n" + + "- Do not invent missing application details.\n" + + @"- Return exactly one answer per question ID in QUESTIONS. +- Do not omit any question IDs from QUESTIONS. +- Do not add keys that are not question IDs from QUESTIONS. +- The ""answer"" value type must match the question type. +- For numeric questions, return a numeric value within the allowed range. +- For yes/no questions, return exactly ""Yes"" or ""No"". +- For select list questions, return only the selected options.number as a string and never return option label text. +- For text and text area questions, return concise, evidence-based text. +- For text and text area questions, include concise source-grounded rationale from the provided input content. +- If explicit evidence is insufficient, choose the most conservative valid answer. +" + + PromptCoreRules.MinimumNarrativeWords + "\n" + + PromptCoreRules.ExactOutputShape + "\n" + + PromptCoreRules.NoExtraOutputKeys + "\n" + + PromptCoreRules.ValidJsonOnly + "\n" + + PromptCoreRules.PlainJsonOnly; + + 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; + } +} + + + From 9ee1136a83d352d957f8c318bdde34baf2268a22 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 6 Mar 2026 16:21:16 -0800 Subject: [PATCH 010/245] AB#32009 Normalize prompt baseline version folders and snapshots --- .../AI/Prompts/Baselines/README.md | 8 + ...ysisPrompts.v1.txt => AnalysisPrompts.txt} | 0 ...ntPrompts.v1.txt => AttachmentPrompts.txt} | 0 ...Service.v0.cs.txt => OpenAIService.cs.txt} | 0 .../Baselines/v0/OpenAIService.v1.cs.txt | 1134 ----------------- ...ptCoreRules.v1.txt => PromptCoreRules.txt} | 0 .../{PromptHeader.v1.txt => PromptHeader.txt} | 0 ...etPrompts.v1.txt => ScoresheetPrompts.txt} | 0 .../Baselines/v1/AnalysisPrompts.cs.txt | 126 ++ .../Baselines/v1/AttachmentPrompts.cs.txt | 27 + .../Prompts/Baselines/v1/OpenAIService.cs.txt | 833 ++++++++++++ .../Baselines/v1/PromptCoreRules.cs.txt | 13 + .../Prompts/Baselines/v1/PromptHeader.cs.txt | 14 + .../Baselines/v1/ScoresheetPrompts.cs.txt | 80 ++ 14 files changed, 1101 insertions(+), 1134 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/README.md rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/{AnalysisPrompts.v1.txt => AnalysisPrompts.txt} (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/{AttachmentPrompts.v1.txt => AttachmentPrompts.txt} (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/{OpenAIService.v0.cs.txt => OpenAIService.cs.txt} (100%) delete mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/{PromptCoreRules.v1.txt => PromptCoreRules.txt} (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/{PromptHeader.v1.txt => PromptHeader.txt} (100%) rename applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/{ScoresheetPrompts.v1.txt => ScoresheetPrompts.txt} (100%) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/AnalysisPrompts.cs.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/AttachmentPrompts.cs.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/OpenAIService.cs.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/PromptCoreRules.cs.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/PromptHeader.cs.txt create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/ScoresheetPrompts.cs.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/README.md b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/README.md new file mode 100644 index 0000000000..4106b7aa0c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/README.md @@ -0,0 +1,8 @@ +# Prompt Baselines + +- `v0`: legacy prompt/service snapshots used as historical baseline. +- `v1`: current runtime prompt/service snapshots. + +Versioning convention: +- Folder name is the baseline version. +- Filenames inside each folder are normalized and do not include version suffixes. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.txt similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.v1.txt rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AnalysisPrompts.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.txt similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.v1.txt rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/AttachmentPrompts.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v0.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.cs.txt similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v0.cs.txt rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.cs.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt deleted file mode 100644 index e421036ebd..0000000000 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/OpenAIService.v1.cs.txt +++ /dev/null @@ -1,1134 +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.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -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 readonly JsonSerializerOptions _prettyJsonOptions = new() { WriteIndented = true }; - - private string? ApiKey => _configuration["AI:OpenAI:ApiKey"]; - private bool HasApiKey => !string.IsNullOrWhiteSpace(ApiKey); - private string? ApiUrl => _configuration["AI:OpenAI:ApiUrl"] ?? "https://api.openai.com/v1/chat/completions"; - private bool LogPayloads => _configuration.GetValue("AI:Logging:LogPayloads") ?? false; - private readonly string NoApiKeyMessage = "OpenAI API key is not configured"; - private const string ServiceNotConfiguredMessage = "AI analysis not available - service not configured."; - private const string ServiceTemporarilyUnavailableMessage = "AI analysis failed - service temporarily unavailable."; - private const string GenericFailureMessage = "AI analysis failed - please try again later."; - private const string NoSummaryGeneratedMessage = "No summary generated."; - private const string EmptyJsonObject = "{}"; - private const int ScoresheetAllMaxTokens = 2000; - private const int ScoresheetSectionMaxTokens = 3200; - private const int AnalysisMaxTokens = 1600; - private const string DefaultContentType = "application/octet-stream"; - private const int AttachmentSummaryMaxTokens = 240; - private const string AttachmentSummaryUnavailableMessage = "AI analysis not available for this attachment"; - private const string ScoreHigh = "HIGH"; - private const string ScoreMedium = "MEDIUM"; - private const string ScoreLow = "LOW"; - private const string AiPromptLogRelativePath = "logs/ai-prompts.log"; - private static int _aiPromptLogInitialized; - - public OpenAIService(HttpClient httpClient, IConfiguration configuration, ILogger logger, ITextExtractionService textExtractionService) - { - _httpClient = httpClient; - _configuration = configuration; - _logger = logger; - _textExtractionService = textExtractionService; - } - - public Task IsAvailableAsync() - { - if (!HasApiKey) - { - _logger.LogWarning("Error: {Message}", NoApiKeyMessage); - return Task.FromResult(false); - } - - return Task.FromResult(true); - } - - public async Task GenerateCompletionAsync(AICompletionRequest request) - { - if (request == null) - { - _logger.LogWarning("AI completion request was null."); - return GenericFailureMessage; - } - - var userPrompt = request.UserPrompt ?? string.Empty; - var systemPrompt = request.SystemPrompt; - var maxTokens = request.MaxTokens <= 0 ? 150 : request.MaxTokens; - var temperature = request.Temperature ?? 0.3; - - return await ExecuteChatCompletionAsync(userPrompt, systemPrompt, maxTokens, temperature); - } - - private async Task ExecuteChatCompletionAsync( - string userPrompt, - string? systemPrompt = null, - int maxTokens = 150, - double temperature = 0.3) - { - if (!HasApiKey) - { - _logger.LogWarning("Error: {Message}", NoApiKeyMessage); - return ServiceNotConfiguredMessage; - } - - _logger.LogDebug( - "Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", - userPrompt?.Length ?? 0, - maxTokens); - - try - { - string resolvedSystemPrompt = systemPrompt ?? "You are a professional grant analyst for the BC Government."; - - var requestBody = new Dictionary - { - ["messages"] = new[] - { - new { role = "system", content = resolvedSystemPrompt }, - new { role = "user", content = userPrompt ?? string.Empty } - }, - ["max_tokens"] = maxTokens, - ["temperature"] = temperature - }; - - var json = JsonSerializer.Serialize(requestBody); - var authValue = ApiKey!.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) - ? ApiKey.Substring("Bearer ".Length).Trim() - : ApiKey; - - using var request = new HttpRequestMessage(HttpMethod.Post, ApiUrl); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authValue); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - - var response = await _httpClient.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync() ?? string.Empty; - - _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; - } - - using var jsonDoc = JsonDocument.Parse(responseContent ?? string.Empty); - 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 GenericFailureMessage; - } - } - - // Canonical attachment summary prompt contract is defined by: - // AttachmentPrompts.SystemPrompt, AttachmentPrompts.OutputSection, and AttachmentPrompts.RulesSection. - public async Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request) - { - try - { - if (request == null) - { - _logger.LogWarning("Attachment summary request was null."); - return $"{AttachmentSummaryUnavailableMessage} (unknown)."; - } - - var normalizedFileName = string.IsNullOrWhiteSpace(request.FileName) ? "unknown" : request.FileName.Trim(); - var normalizedContentType = string.IsNullOrWhiteSpace(request.ContentType) ? DefaultContentType : request.ContentType.Trim(); - var normalizedFileContent = request.FileContent ?? Array.Empty(); - var extractedText = await _textExtractionService.ExtractTextAsync(normalizedFileName, normalizedFileContent, normalizedContentType); - - var hasExtractedText = !string.IsNullOrWhiteSpace(extractedText); - if (hasExtractedText) - { - _logger.LogDebug("Extracted {TextLength} characters from {FileName}", extractedText!.Length, normalizedFileName); - } - else - { - _logger.LogDebug("No text extracted from {FileName}, analyzing metadata only", normalizedFileName); - } - - var attachmentInput = new AttachmentPromptInput - { - Name = normalizedFileName, - Text = hasExtractedText ? extractedText : null - }; - - var contentToAnalyze = BuildAttachmentSummaryPrompt(attachmentInput); - return await ExecutePromptWithRetryAsync( - promptType: "AttachmentSummary", - systemPrompt: AttachmentPrompts.SystemPrompt, - userPrompt: contentToAnalyze, - maxTokens: AttachmentSummaryMaxTokens, - normalizeResponse: NormalizeAttachmentSummaryResponse, - isValidNormalizedResponse: normalized => !string.IsNullOrWhiteSpace(normalized), - fallbackResponse: string.Empty); - } - catch (Exception ex) - { - var fileName = request?.FileName ?? "unknown"; - _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); - return $"{AttachmentSummaryUnavailableMessage} ({fileName})."; - } - } - - private string BuildAttachmentSummaryPrompt(AttachmentPromptInput attachmentInput) - { - return $@"ATTACHMENT -{JsonSerializer.Serialize(attachmentInput, _prettyJsonOptions)} - -{AttachmentPrompts.OutputSection} - -RULES -{AttachmentPrompts.RulesSection}"; - } - - private string NormalizeAttachmentSummaryResponse(string response) - { - if (!TryParseJsonObjectFromResponse(response, out var responseObject)) - { - return string.Empty; - } - - if (responseObject.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && - summaryProp.ValueKind == JsonValueKind.String) - { - return summaryProp.GetString()?.Trim() ?? string.Empty; - } - - return responseObject.ToString().Trim(); - } - - // Canonical analysis prompt contract is defined by: - // AnalysisPrompts.DefaultRubric, AnalysisPrompts.ScoreRules, AnalysisPrompts.OutputTemplate, - // AnalysisPrompts.Rules, and AnalysisPrompts.SystemPrompt. - public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) - { - if (!HasApiKey) - { - _logger.LogWarning("{Message}", NoApiKeyMessage); - return ServiceNotConfiguredMessage; - } - - try - { - if (request == null) - { - _logger.LogWarning("Application analysis request was null."); - return BuildEmptyAnalysisResponseJson(); - } - - var emptyObject = CreateEmptyJsonObject(); - var schemaPayload = request.Schema.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Schema; - var dataPayload = request.Data.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Data; - - if (schemaPayload.ValueKind != JsonValueKind.Object || dataPayload.ValueKind != JsonValueKind.Object) - { - _logger.LogWarning( - "Invalid application analysis request payload shape. Schema kind: {SchemaKind}, Data kind: {DataKind}.", - schemaPayload.ValueKind, - dataPayload.ValueKind); - return BuildEmptyAnalysisResponseJson(); - } - - var attachmentsPayload = request.Attachments? - .Where(a => a != null && !string.IsNullOrWhiteSpace(a.Summary)) - .Select(a => new ApplicationAnalysisAttachment - { - Name = string.IsNullOrWhiteSpace(a.Name) ? "attachment" : a.Name.Trim(), - Summary = a.Summary.Trim() - }) - .ToList() ?? new List(); - - var rubricText = !string.IsNullOrWhiteSpace(request.Rubric) ? request.Rubric : AnalysisPrompts.DefaultRubric; - var analysisPrompt = BuildAnalysisPrompt(schemaPayload, dataPayload, attachmentsPayload, rubricText); - return await ExecutePromptWithRetryAsync( - promptType: "ApplicationAnalysis", - systemPrompt: AnalysisPrompts.SystemPrompt, - userPrompt: analysisPrompt, - maxTokens: AnalysisMaxTokens, - normalizeResponse: NormalizeAnalysisResponse, - isValidNormalizedResponse: IsValidAnalysisNormalizedResponse, - fallbackResponse: BuildEmptyAnalysisResponseJson()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error analyzing application"); - return BuildEmptyAnalysisResponseJson(); - } - } - - private string NormalizeAnalysisResponse(string analysisJson) - { - try - { - if (!TryParseJsonObjectFromResponse(analysisJson, out var analysisObject)) - { - _logger.LogError("Invalid analysis JSON response."); - return BuildEmptyAnalysisResponseJson(); - } - - var parseOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - var model = JsonSerializer.Deserialize(analysisObject.GetRawText(), parseOptions); - if (model == null) - { - return BuildEmptyAnalysisResponseJson(); - } - - model.Errors ??= new List(); - model.Warnings ??= new List(); - model.Summaries ??= new List(); - model.Dismissed ??= new List(); - - model.Rating = NormalizeRating(model.Rating); - - foreach (var error in model.Errors) - { - error.Id = string.IsNullOrWhiteSpace(error.Id) ? Guid.NewGuid().ToString() : error.Id; - } - - foreach (var warning in model.Warnings) - { - warning.Id = string.IsNullOrWhiteSpace(warning.Id) ? Guid.NewGuid().ToString() : warning.Id; - } - - model.Dismissed = model.Dismissed - .Where(id => !string.IsNullOrWhiteSpace(id)) - .Distinct(StringComparer.Ordinal) - .ToList(); - - var normalizedOutput = new ApplicationAnalysisResponse - { - Rating = model.Rating, - Errors = model.Errors, - Warnings = model.Warnings, - Summaries = model.Summaries, - Dismissed = model.Dismissed - }; - - return JsonSerializer.Serialize(normalizedOutput, _prettyJsonOptions); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error normalizing analysis response."); - return BuildEmptyAnalysisResponseJson(); - } - } - - private static string NormalizeRating(string? rating) - { - var normalized = rating?.Trim().ToUpperInvariant(); - return normalized switch - { - ScoreHigh => ScoreHigh, - ScoreMedium => ScoreMedium, - ScoreLow => ScoreLow, - _ => ScoreMedium - }; - } - - private string BuildAnalysisPrompt( - JsonElement schemaPayload, - JsonElement dataPayload, - List attachmentsPayload, - string rubricText) - { - var analysisAttachments = (attachmentsPayload ?? new List()) - .Select(a => new AnalysisAttachmentPromptItem - { - Name = a.Name, - Summary = a.Summary - }) - .ToList(); - - return $@"SCHEMA -{JsonSerializer.Serialize(schemaPayload, _prettyJsonOptions)} - -DATA -{JsonSerializer.Serialize(dataPayload, _prettyJsonOptions)} - -ATTACHMENTS -{JsonSerializer.Serialize(analysisAttachments, _prettyJsonOptions)} - -RUBRIC -{rubricText ?? AnalysisPrompts.DefaultRubric} - -SCORE -{AnalysisPrompts.ScoreRules} - -OUTPUT -{AnalysisPrompts.OutputTemplate} - -RULES -{AnalysisPrompts.Rules}"; - } - // Canonical scoresheet-all prompt contract is defined by: - // ScoresheetPrompts.AllSystemPrompt, ScoresheetPrompts.AllOutputTemplate, and ScoresheetPrompts.AllRules. - public async Task GenerateScoresheetAllAnswersAsync(ScoresheetAllRequest request) - { - if (!HasApiKey) - { - _logger.LogWarning("{Message}", NoApiKeyMessage); - return EmptyJsonObject; - } - - try - { - if (request == null) - { - _logger.LogWarning("Scoresheet-all request was null."); - return EmptyJsonObject; - } - - if (!IsValidScoresheetQuestionsPayload(request.Questions)) - { - _logger.LogWarning( - "Invalid scoresheet-all questions payload shape. Questions kind: {QuestionsKind}.", - request.Questions.ValueKind); - return EmptyJsonObject; - } - - var scoresheetPrompt = BuildScoresheetAllPrompt(request); - return await ExecutePromptWithRetryAsync( - promptType: "ScoresheetAll", - systemPrompt: ScoresheetPrompts.AllSystemPrompt, - userPrompt: scoresheetPrompt, - maxTokens: ScoresheetAllMaxTokens, - normalizeResponse: NormalizeScoresheetAllResponse, - isValidNormalizedResponse: normalized => !IsEmptyJsonObject(normalized), - fallbackResponse: EmptyJsonObject); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating scoresheet answers"); - return EmptyJsonObject; - } - } - // Canonical scoresheet-section prompt contract is defined by: - // ScoresheetPrompts.SectionSystemPrompt, ScoresheetPrompts.SectionOutputTemplate, and ScoresheetPrompts.SectionRules. - public async Task GenerateScoresheetSectionAnswersAsync(ScoresheetSectionRequest request) - { - if (!HasApiKey) - { - _logger.LogWarning("{Message}", NoApiKeyMessage); - return EmptyJsonObject; - } - - try - { - if (request == null) - { - _logger.LogWarning("Scoresheet-section request was null."); - return EmptyJsonObject; - } - - if (!IsValidScoresheetSectionSchemaPayload(request.SectionSchema)) - { - _logger.LogWarning( - "Invalid scoresheet-section schema payload shape. SectionSchema kind: {SectionSchemaKind}.", - request.SectionSchema.ValueKind); - return EmptyJsonObject; - } - - var scoresheetSectionPrompt = BuildScoresheetSectionPrompt(request); - return await ExecutePromptWithRetryAsync( - promptType: "ScoresheetSection", - systemPrompt: ScoresheetPrompts.SectionSystemPrompt, - userPrompt: scoresheetSectionPrompt, - maxTokens: ScoresheetSectionMaxTokens, - normalizeResponse: raw => NormalizeScoresheetSectionResponse(raw, request.SectionSchema), - isValidNormalizedResponse: normalized => IsCompleteScoresheetSectionResponse(normalized, request.SectionSchema), - fallbackResponse: EmptyJsonObject); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating scoresheet section answers for section {SectionName}", request?.SectionName); - return EmptyJsonObject; - } - } - - private string BuildScoresheetAllPrompt(ScoresheetAllRequest request) - { - var emptyObject = CreateEmptyJsonObject(); - var questionsPayload = request.Questions.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Questions; - var dataPayload = request.Data.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Data; - var attachmentsPayload = BuildScoresheetAttachmentPromptItems(request.Attachments); - - return $@"DATA -{JsonSerializer.Serialize(dataPayload, _prettyJsonOptions)} - -ATTACHMENTS -{JsonSerializer.Serialize(attachmentsPayload, _prettyJsonOptions)} - -QUESTIONS -{JsonSerializer.Serialize(questionsPayload, _prettyJsonOptions)} - -OUTPUT -{ScoresheetPrompts.AllOutputTemplate} - -RULES -{ScoresheetPrompts.AllRules}"; - } - - private string BuildScoresheetSectionPrompt(ScoresheetSectionRequest request) - { - var emptyObject = CreateEmptyJsonObject(); - var sectionSchemaPayload = request.SectionSchema.ValueKind == JsonValueKind.Undefined ? emptyObject : request.SectionSchema; - var dataPayload = request.Data.ValueKind == JsonValueKind.Undefined ? emptyObject : request.Data; - var attachmentsPayload = BuildScoresheetAttachmentPromptItems(request.Attachments); - var responseTemplate = BuildScoresheetSectionResponseTemplate(sectionSchemaPayload); - var sectionName = request.SectionName ?? string.Empty; - var section = new - { - name = sectionName, - questions = sectionSchemaPayload - }; - - return $@"DATA -{JsonSerializer.Serialize(dataPayload, _prettyJsonOptions)} - -ATTACHMENTS -{JsonSerializer.Serialize(attachmentsPayload, _prettyJsonOptions)} - -SECTION -{JsonSerializer.Serialize(section, _prettyJsonOptions)} - -RESPONSE -{JsonSerializer.Serialize(responseTemplate, _prettyJsonOptions)} - -OUTPUT -{ScoresheetPrompts.SectionOutputTemplate} - -RULES -{ScoresheetPrompts.SectionRules}"; - } - - private static Dictionary BuildScoresheetSectionResponseTemplate(JsonElement sectionSchemaPayload) - { - var template = new Dictionary(StringComparer.Ordinal); - var questions = EnumerateSectionQuestions(sectionSchemaPayload); - - foreach (var question in questions) - { - if (!TryGetQuestionId(question, out var questionId)) - { - continue; - } - - template[questionId] = new Dictionary - { - [AIJsonKeys.Answer] = string.Empty, - [AIJsonKeys.Rationale] = string.Empty, - [AIJsonKeys.Confidence] = 0 - }; - } - - return template; - } - - private static List BuildScoresheetAttachmentPromptItems(List attachments) - { - return attachments? - .Where(attachment => attachment != null && !string.IsNullOrWhiteSpace(attachment.Summary)) - .Select(attachment => (object)new - { - name = string.IsNullOrWhiteSpace(attachment.Name) ? "attachment" : attachment.Name.Trim(), - summary = attachment.Summary.Trim() - }) - .ToList() ?? new List(); - } - - private static bool IsValidScoresheetQuestionsPayload(JsonElement questions) - { - return questions.ValueKind == JsonValueKind.Object || questions.ValueKind == JsonValueKind.Array; - } - - private static bool IsValidScoresheetSectionSchemaPayload(JsonElement sectionSchema) - { - return sectionSchema.ValueKind == JsonValueKind.Object || sectionSchema.ValueKind == JsonValueKind.Array; - } - - private string BuildEmptyAnalysisResponseJson() - { - var emptyResponse = new ApplicationAnalysisResponse - { - Rating = ScoreMedium, - Errors = new List(), - Warnings = new List(), - Summaries = new List(), - Dismissed = new List() - }; - - return JsonSerializer.Serialize(emptyResponse, _prettyJsonOptions); - } - - private static JsonElement CreateEmptyJsonObject() - { - return JsonSerializer.SerializeToElement(new { }); - } - - private string NormalizeScoresheetAllResponse(string response) - { - if (!TryParseJsonObjectFromResponse(response, out var responseObject)) - { - _logger.LogError("Invalid scoresheet-all JSON response."); - return EmptyJsonObject; - } - - return JsonSerializer.Serialize(responseObject, _prettyJsonOptions); - } - - private string NormalizeScoresheetSectionResponse(string response, JsonElement sectionSchemaPayload) - { - if (!TryParseJsonObjectFromResponse(response, out var responseObject)) - { - _logger.LogError("Invalid scoresheet-section JSON response."); - return EmptyJsonObject; - } - - var questionSpecs = BuildSectionQuestionSpecs(sectionSchemaPayload); - var normalized = new Dictionary(); - IEnumerable questionIds = questionSpecs.Count > 0 - ? questionSpecs.Keys - : responseObject.EnumerateObject().Select(p => p.Name); - - foreach (var questionId in questionIds) - { - responseObject.TryGetProperty(questionId, out var value); - var answer = value.ValueKind == JsonValueKind.Undefined ? string.Empty : value.ToString(); - var rationale = string.Empty; - var confidence = 0; - - if (value.ValueKind == JsonValueKind.Object) - { - if (value.TryGetProperty(AIJsonKeys.Answer, out var answerProp)) - { - answer = answerProp.ToString(); - } - - if (value.TryGetProperty(AIJsonKeys.Rationale, out var rationaleProp)) - { - rationale = rationaleProp.ToString(); - } - - if (value.TryGetProperty(AIJsonKeys.Confidence, out var confidenceProp)) - { - confidence = NormalizeConfidenceIncrement(ParseConfidenceValue(confidenceProp)); - } - } - - questionSpecs.TryGetValue(questionId, out var questionSpec); - var normalizedAnswer = NormalizeAnswerByQuestionType(answer, questionSpec); - var normalizedRationale = rationale?.Trim() ?? string.Empty; - var normalizedConfidence = NormalizeConfidenceIncrement(confidence); - - normalized[questionId] = new Dictionary - { - [AIJsonKeys.Answer] = normalizedAnswer, - [AIJsonKeys.Rationale] = normalizedRationale, - [AIJsonKeys.Confidence] = normalizedConfidence - }; - } - - return JsonSerializer.Serialize(normalized, _prettyJsonOptions); - } - - private static Dictionary BuildSectionQuestionSpecs(JsonElement sectionSchemaPayload) - { - var specs = new Dictionary(StringComparer.Ordinal); - foreach (var question in EnumerateSectionQuestions(sectionSchemaPayload)) - { - if (!TryGetQuestionId(question, out var questionId)) - { - continue; - } - - var spec = new SectionQuestionSpec - { - QuestionType = question.TryGetProperty("type", out var typeProp) - ? typeProp.GetString() ?? string.Empty - : string.Empty - }; - - if (question.TryGetProperty("options", out var options) && options.ValueKind == JsonValueKind.Array) - { - foreach (var option in options.EnumerateArray()) - { - if (!option.TryGetProperty("number", out var numberProp)) - { - continue; - } - - var number = numberProp.ValueKind == JsonValueKind.Number - ? numberProp.GetInt32().ToString() - : numberProp.ToString(); - - if (string.IsNullOrWhiteSpace(number)) - { - continue; - } - - spec.OptionNumbers.Add(number); - var label = option.TryGetProperty("value", out var valueProp) ? valueProp.ToString() : string.Empty; - spec.OptionLabels[number] = label ?? string.Empty; - } - } - - specs[questionId] = spec; - } - - return specs; - } - - private static IEnumerable EnumerateSectionQuestions(JsonElement sectionSchemaPayload) - { - if (sectionSchemaPayload.ValueKind == JsonValueKind.Array) - { - foreach (var question in sectionSchemaPayload.EnumerateArray()) - { - if (question.ValueKind == JsonValueKind.Object) - { - yield return question; - } - } - } - else if (sectionSchemaPayload.ValueKind == JsonValueKind.Object && - sectionSchemaPayload.TryGetProperty("questions", out var questions) && - questions.ValueKind == JsonValueKind.Array) - { - foreach (var question in questions.EnumerateArray()) - { - if (question.ValueKind == JsonValueKind.Object) - { - yield return question; - } - } - } - } - - private static bool TryGetQuestionId(JsonElement question, out string questionId) - { - questionId = string.Empty; - if (!question.TryGetProperty("id", out var idProp) || idProp.ValueKind != JsonValueKind.String) - { - return false; - } - - questionId = idProp.GetString() ?? string.Empty; - return !string.IsNullOrWhiteSpace(questionId); - } - - private static object NormalizeAnswerByQuestionType(string answer, SectionQuestionSpec? questionSpec) - { - var normalizedAnswer = answer?.Trim() ?? string.Empty; - var questionType = questionSpec?.QuestionType ?? string.Empty; - - if (questionType.Equals("YesNo", StringComparison.OrdinalIgnoreCase)) - { - if (normalizedAnswer.Equals("Yes", StringComparison.OrdinalIgnoreCase)) - { - return "Yes"; - } - - if (normalizedAnswer.Equals("No", StringComparison.OrdinalIgnoreCase)) - { - return "No"; - } - - return "No"; - } - - if (questionType.Equals("Number", StringComparison.OrdinalIgnoreCase)) - { - if (decimal.TryParse(normalizedAnswer, out var decimalAnswer)) - { - return decimalAnswer; - } - - return 0; - } - - if (questionType.Equals("SelectList", StringComparison.OrdinalIgnoreCase)) - { - return NormalizeSelectListAnswer(normalizedAnswer, questionSpec); - } - - if (questionType.Equals("Text", StringComparison.OrdinalIgnoreCase) || - questionType.Equals("TextArea", StringComparison.OrdinalIgnoreCase)) - { - return normalizedAnswer; - } - - return normalizedAnswer; - } - - private static string NormalizeSelectListAnswer(string answer, SectionQuestionSpec? questionSpec) - { - var options = questionSpec?.OptionNumbers ?? new List(); - if (options.Count == 0) - { - return answer; - } - - if (options.Contains(answer)) - { - return answer; - } - - if (int.TryParse(answer, out var parsedAnswer) && options.Contains(parsedAnswer.ToString())) - { - return parsedAnswer.ToString(); - } - - return answer; - } - - private sealed class SectionQuestionSpec - { - public string QuestionType { get; set; } = string.Empty; - public List OptionNumbers { get; set; } = new(); - public Dictionary OptionLabels { get; set; } = new(StringComparer.OrdinalIgnoreCase); - } - - private static int NormalizeConfidenceIncrement(int confidence) - { - var rounded = (int)Math.Round(confidence / 5.0, MidpointRounding.AwayFromZero) * 5; - return Math.Clamp(rounded, 0, 100); - } - - private static int ParseConfidenceValue(JsonElement confidenceProp) - { - if (confidenceProp.ValueKind == JsonValueKind.Number) - { - if (confidenceProp.TryGetInt32(out var intValue)) - { - return intValue; - } - - if (confidenceProp.TryGetDouble(out var doubleValue)) - { - return (int)Math.Round(doubleValue, MidpointRounding.AwayFromZero); - } - } - - if (confidenceProp.ValueKind == JsonValueKind.String) - { - var raw = confidenceProp.GetString(); - if (int.TryParse(raw, out var parsedInt)) - { - return parsedInt; - } - - if (double.TryParse(raw, out var parsedDouble)) - { - return (int)Math.Round(parsedDouble, MidpointRounding.AwayFromZero); - } - } - - return 0; - } - - private void LogPromptOutput(string promptType, string output) - { - if (!LogPayloads) - { - return; - } - - var formattedOutput = FormatPromptOutputForLog(promptType, output); - _logger.LogDebug( - "AI {PromptType} model output payload: {ModelOutput}", - promptType, - formattedOutput); - WriteAiPromptLog(promptType, "OUTPUT", formattedOutput); - } - - private void LogPromptInput(string promptType, string? systemPrompt, string userPrompt) - { - if (!LogPayloads) - { - return; - } - - var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogDebug( - "AI {PromptType} input payload: {PromptInput}", - promptType, - formattedInput); - WriteAiPromptLog(promptType, "INPUT", formattedInput); - } - - private void WriteAiPromptLog(string promptType, string payloadType, string payload) - { - if (!LogPayloads) - { - return; - } - - try - { - var now = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss zzz"); - var logPath = Path.Combine(AppContext.BaseDirectory, AiPromptLogRelativePath); - EnsureAiPromptLogInitialized(logPath); - - var entry = $"{now} [{promptType}] {payloadType}\n{payload}\n\n"; - File.AppendAllText(logPath, entry); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to write AI prompt log file."); - } - } - - private static void EnsureAiPromptLogInitialized(string logPath) - { - var directory = Path.GetDirectoryName(logPath); - if (!string.IsNullOrWhiteSpace(directory)) - { - Directory.CreateDirectory(directory); - } - - // Reset once per process run so each fresh app run starts with a clean AI prompt log. - if (Interlocked.Exchange(ref _aiPromptLogInitialized, 1) == 0) - { - File.WriteAllText(logPath, string.Empty); - } - } - - 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 string FormatPromptOutputForLog(string promptType, string output) - { - if (string.IsNullOrWhiteSpace(output)) - { - return string.Empty; - } - - // For JSON contracts, log only normalized payload JSON. - if (TryParseJsonObjectFromResponse(output, out var jsonObject)) - { - return JsonSerializer.Serialize(jsonObject, _prettyJsonOptions); - } - - 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 bool IsEmptyJsonObject(string json) - { - if (string.IsNullOrWhiteSpace(json)) - { - return true; - } - - try - { - using var doc = JsonDocument.Parse(json); - return doc.RootElement.ValueKind == JsonValueKind.Object && - !doc.RootElement.EnumerateObject().Any(); - } - catch (JsonException) - { - return true; - } - } - - private async Task ExecutePromptWithRetryAsync( - string promptType, - string systemPrompt, - string userPrompt, - int maxTokens, - Func normalizeResponse, - Func isValidNormalizedResponse, - string fallbackResponse, - int maxAttempts = 2) - { - LogPromptInput(promptType, systemPrompt, userPrompt); - - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - var rawResponse = await GenerateCompletionAsync(new AICompletionRequest - { - UserPrompt = userPrompt, - SystemPrompt = systemPrompt, - MaxTokens = maxTokens - }); - - var outputType = attempt == 1 ? promptType : $"{promptType}Retry"; - LogPromptOutput(outputType, rawResponse); - - var normalized = normalizeResponse(rawResponse); - if (isValidNormalizedResponse(normalized)) - { - return normalized; - } - - if (attempt < maxAttempts) - { - _logger.LogWarning( - "{PromptType} response failed output-shape validation on attempt {Attempt}/{MaxAttempts}. Retrying.", - promptType, - attempt, - maxAttempts); - } - } - - return fallbackResponse; - } - - private static bool IsValidAnalysisNormalizedResponse(string normalizedJson) - { - if (!TryParseJsonObjectFromResponse(normalizedJson, out var root)) - { - return false; - } - - return root.TryGetProperty("rating", out _) && - root.TryGetProperty("errors", out _) && - root.TryGetProperty("warnings", out _) && - root.TryGetProperty("summaries", out _); - } - - private static bool IsCompleteScoresheetSectionResponse(string normalizedJson, JsonElement sectionSchemaPayload) - { - if (!TryParseJsonObjectFromResponse(normalizedJson, out var root)) - { - return false; - } - - var expectedQuestionIds = EnumerateSectionQuestions(sectionSchemaPayload) - .Select(q => TryGetQuestionId(q, out var id) ? id : string.Empty) - .Where(id => !string.IsNullOrWhiteSpace(id)) - .ToList(); - - if (expectedQuestionIds.Count == 0) - { - return false; - } - - foreach (var questionId in expectedQuestionIds) - { - if (!root.TryGetProperty(questionId, out var answerObject) || answerObject.ValueKind != JsonValueKind.Object) - { - return false; - } - - if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerProp) || - string.IsNullOrWhiteSpace(answerProp.ToString())) - { - return false; - } - - if (!answerObject.TryGetProperty(AIJsonKeys.Rationale, out var rationaleProp) || - string.IsNullOrWhiteSpace(rationaleProp.ToString())) - { - return false; - } - - if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out _)) - { - return false; - } - } - - return true; - } - - 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) - { - cleaned = cleaned.Substring(startIndex + 1); - } - } - - if (cleaned.EndsWith("```", StringComparison.Ordinal)) - { - var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); - if (lastIndex > 0) - { - cleaned = cleaned.Substring(0, lastIndex); - } - } - - return cleaned.Trim(); - } - } -} - - diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.txt similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.v1.txt rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptCoreRules.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptHeader.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptHeader.txt similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptHeader.v1.txt rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/PromptHeader.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/ScoresheetPrompts.v1.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/ScoresheetPrompts.txt similarity index 100% rename from applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/ScoresheetPrompts.v1.txt rename to applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v0/ScoresheetPrompts.txt diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/AnalysisPrompts.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/AnalysisPrompts.cs.txt new file mode 100644 index 0000000000..2e54412806 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/AnalysisPrompts.cs.txt @@ -0,0 +1,126 @@ +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/Baselines/v1/AttachmentPrompts.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/AttachmentPrompts.cs.txt new file mode 100644 index 0000000000..969480ea86 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/AttachmentPrompts.cs.txt @@ -0,0 +1,27 @@ +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/Baselines/v1/OpenAIService.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/OpenAIService.cs.txt new file mode 100644 index 0000000000..418c31ebcf --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/OpenAIService.cs.txt @@ -0,0 +1,833 @@ +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/Prompts/Baselines/v1/PromptCoreRules.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/PromptCoreRules.cs.txt new file mode 100644 index 0000000000..e11dce3c97 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/PromptCoreRules.cs.txt @@ -0,0 +1,13 @@ +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/Baselines/v1/PromptHeader.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/PromptHeader.cs.txt new file mode 100644 index 0000000000..701a43e740 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/PromptHeader.cs.txt @@ -0,0 +1,14 @@ +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/Baselines/v1/ScoresheetPrompts.cs.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/ScoresheetPrompts.cs.txt new file mode 100644 index 0000000000..2db4de742d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Baselines/v1/ScoresheetPrompts.cs.txt @@ -0,0 +1,80 @@ +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}"; + } + } +} From 979e7b97ed1c14866bd6069dfdf661c3e4caa0fb Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 6 Mar 2026 16:58:18 -0800 Subject: [PATCH 011/245] AB#32009 Add runtime prompt version selector with single-file v0/v1 profiles --- .../AI/OpenAIService.cs | 78 ++++++++++----- .../AI/Prompts/AnalysisPrompts.cs | 98 ++++++++++++++++--- .../AI/Prompts/AttachmentPrompts.cs | 32 ++++++ .../AI/Prompts/ScoresheetPrompts.cs | 43 ++++++++ 4 files changed, 211 insertions(+), 40 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs index 418c31ebcf..9c1ecd11a1 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -22,6 +22,8 @@ public class OpenAIService : IAIService, ITransientDependency private const string AttachmentSummaryPromptType = "AttachmentSummary"; private const string ScoresheetAllPromptType = "ScoresheetAll"; private const string ScoresheetSectionPromptType = "ScoresheetSection"; + private const string PromptVersionV0 = "v0"; + private const string PromptVersionV1 = "v1"; 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."; @@ -39,6 +41,9 @@ public class OpenAIService : IAIService, ITransientDependency private static readonly JsonSerializerOptions JsonLogOptions = new() { WriteIndented = true }; + private string SelectedPromptVersion => NormalizePromptVersion(_configuration["Azure:OpenAI:PromptVersion"]); + private bool UseV0Prompts => string.Equals(SelectedPromptVersion, PromptVersionV0, StringComparison.OrdinalIgnoreCase); + public OpenAIService( HttpClient httpClient, IConfiguration configuration, @@ -84,13 +89,10 @@ public async Task GenerateApplicationAnalysisAsync( }) .Cast(); - var analysisContent = AnalysisPrompts.BuildUserPrompt( - schemaJson, - dataJson, - JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions), - request.Rubric ?? string.Empty); - - var systemPrompt = AnalysisPrompts.SystemPrompt; + var attachmentsJson = JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions); + var rubric = request.Rubric ?? AnalysisPrompts.GetRubric(UseV0Prompts); + var analysisContent = AnalysisPrompts.BuildUserPrompt(schemaJson, dataJson, attachmentsJson, rubric, UseV0Prompts); + var systemPrompt = AnalysisPrompts.GetSystemPrompt(UseV0Prompts); await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); await LogPromptOutputAsync(ApplicationAnalysisPromptType, raw); @@ -171,11 +173,11 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] { var extractedText = await _textExtractionService.ExtractTextAsync(fileName, fileContent, contentType); - var prompt = $@"{AttachmentPrompts.SystemPrompt} + var prompt = $@"{AttachmentPrompts.GetSystemPrompt(UseV0Prompts)} -{AttachmentPrompts.OutputSection} +{AttachmentPrompts.GetOutputSection(UseV0Prompts)} -{AttachmentPrompts.RulesSection}"; +{AttachmentPrompts.GetRulesSection(UseV0Prompts)}"; var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; if (attachmentText != null) @@ -194,13 +196,13 @@ public async Task GenerateAttachmentSummaryAsync(string fileName, byte[] sizeBytes = fileContent.Length, text = attachmentText }; - var contentToAnalyze = AttachmentPrompts.BuildUserPrompt( - JsonSerializer.Serialize(attachmentPayload, JsonLogOptions)); + var payloadJson = JsonSerializer.Serialize(attachmentPayload, JsonLogOptions); + var contentToAnalyze = AttachmentPrompts.BuildUserPrompt(payloadJson, UseV0Prompts); await LogPromptInputAsync(AttachmentSummaryPromptType, prompt, contentToAnalyze); var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); await LogPromptOutputAsync(AttachmentSummaryPromptType, modelOutput); - return modelOutput; + return UseV0Prompts ? ExtractSummaryFromJson(modelOutput) : modelOutput; } catch (Exception ex) { @@ -257,13 +259,14 @@ public async Task AnalyzeApplicationAsync(string applicationContent, Lis .Cast() : Enumerable.Empty(); - var analysisContent = AnalysisPrompts.BuildUserPrompt( - JsonSerializer.Serialize(schemaPayload, JsonLogOptions), - JsonSerializer.Serialize(dataPayload, JsonLogOptions), - JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions), - rubric); - - var systemPrompt = AnalysisPrompts.SystemPrompt; + var schemaJson = JsonSerializer.Serialize(schemaPayload, JsonLogOptions); + var dataJson = JsonSerializer.Serialize(dataPayload, JsonLogOptions); + var attachmentsJson = JsonSerializer.Serialize(attachmentsPayload, JsonLogOptions); + var fallbackRubric = string.IsNullOrWhiteSpace(rubric) + ? AnalysisPrompts.GetRubric(UseV0Prompts) + : rubric; + var analysisContent = AnalysisPrompts.BuildUserPrompt(schemaJson, dataJson, attachmentsJson, fallbackRubric, UseV0Prompts); + var systemPrompt = AnalysisPrompts.GetSystemPrompt(UseV0Prompts); await LogPromptInputAsync(ApplicationAnalysisPromptType, systemPrompt, analysisContent); var rawAnalysis = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); @@ -446,9 +449,10 @@ public async Task GenerateScoresheetSectionAnswersAsync(string applicati applicationContent, attachmentSummariesText, sectionPayloadJson, - responseTemplate); + responseTemplate, + UseV0Prompts); - var systemPrompt = ScoresheetPrompts.SectionSystemPrompt; + var systemPrompt = ScoresheetPrompts.GetSectionSystemPrompt(UseV0Prompts); await LogPromptInputAsync(ScoresheetSectionPromptType, systemPrompt, analysisContent); var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); @@ -683,14 +687,14 @@ private static string BuildScoresheetSectionResponseTemplate(string sectionPaylo private async Task LogPromptInputAsync(string promptType, string? systemPrompt, string userPrompt) { var formattedInput = FormatPromptInputForLog(systemPrompt, userPrompt); - _logger.LogInformation("AI {PromptType} input payload: {PromptInput}", promptType, formattedInput); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) input payload: {PromptInput}", promptType, SelectedPromptVersion, 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); + _logger.LogInformation("AI {PromptType} ({PromptVersion}) model output payload: {ModelOutput}", promptType, SelectedPromptVersion, formattedOutput); await WritePromptLogFileAsync(promptType, "OUTPUT", formattedOutput); } @@ -829,5 +833,31 @@ private static int FindFirstJsonTokenIndex(string value) return arrayStart; } + + private static string NormalizePromptVersion(string? version) + { + if (string.Equals(version, PromptVersionV0, StringComparison.OrdinalIgnoreCase)) + { + return PromptVersionV0; + } + + return PromptVersionV1; + } + + private static string ExtractSummaryFromJson(string output) + { + if (!TryParseJsonObjectFromResponse(output, out var jsonObject)) + { + return output?.Trim() ?? string.Empty; + } + + if (jsonObject.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && + summaryProp.ValueKind == JsonValueKind.String) + { + return summaryProp.GetString() ?? string.Empty; + } + + return output?.Trim() ?? string.Empty; + } } } 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 index 2e54412806..97841e760a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs @@ -2,6 +2,12 @@ namespace Unity.GrantManager.AI { internal static class AnalysisPrompts { + public const string DefaultRubricV0 = @"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."; + public const string DefaultRubric = @"BC GOVERNMENT GRANT EVALUATION RUBRIC: 1. ELIGIBILITY REQUIREMENTS: @@ -43,6 +49,45 @@ internal static class AnalysisPrompts 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 OutputTemplateV0 = @"{ + ""rating"": """", + ""errors"": [ + { + ""title"": """", + ""detail"": """" + } + ], + ""warnings"": [ + { + ""title"": """", + ""detail"": """" + } + ], + ""summaries"": [ + { + ""title"": """", + ""detail"": """" + } + ] +}"; + + public const string RulesV0 = PromptCoreRules.UseProvidedEvidence + "\n" + + "- Do not invent fields, documents, requirements, or facts.\n" + + @"- Treat missing or empty values as findings only when they weaken rubric evidence. +- Prefer material issues; avoid nitpicking. +- Use 3-6 words for title. +- Each detail must be 1-2 complete sentences. +- Each detail must cite concrete evidence from DATA or ATTACHMENTS. +- If ATTACHMENTS evidence is used, cite the attachment by name in detail. +- If no findings exist, return empty arrays. +- Rating must be HIGH, MEDIUM, or LOW. +" + + PromptCoreRules.MinimumNarrativeWords + "\n" + + PromptCoreRules.ExactOutputShape + "\n" + + PromptCoreRules.NoExtraOutputKeys + "\n" + + PromptCoreRules.ValidJsonOnly + "\n" + + PromptCoreRules.PlainJsonOnly; + 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."; @@ -51,20 +96,20 @@ internal static class AnalysisPrompts ""rating"": ""HIGH/MEDIUM/LOW"", ""warnings"": [ { - ""title"": ""Brief summary of the warning"", - ""detail"": ""Detailed warning message with full context and explanation"" + ""category"": ""Brief summary of the warning"", + ""message"": ""Detailed warning message with full context and explanation"" } ], ""errors"": [ { - ""title"": ""Brief summary of the error"", - ""detail"": ""Detailed error message with full context and explanation"" + ""category"": ""Brief summary of the error"", + ""message"": ""Detailed error message with full context and explanation"" } ], ""summaries"": [ { - ""title"": ""Brief summary of the recommendation"", - ""detail"": ""Detailed recommendation with specific actionable guidance"" + ""category"": ""Brief summary of the recommendation"", + ""message"": ""Detailed recommendation with specific actionable guidance"" } ], ""dismissed"": [] @@ -75,10 +120,10 @@ internal static class AnalysisPrompts - 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. +- Use 3-6 words for category. +- Each message must be 1-2 complete sentences. +- Each message must be grounded in concrete evidence from provided inputs. +- If attachment evidence is used, reference the attachment explicitly in the message. - Do not provide applicant-facing advice. - Do not mention rubric section names in findings. - If no findings exist, return empty arrays. @@ -92,12 +137,36 @@ internal static class AnalysisPrompts "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 readonly string SystemPromptV0 = PromptHeader.Build( + "You are an expert grant analyst assistant for human reviewers.", + "Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES, return review findings."); + + public static string GetRubric(bool useV0) => useV0 ? DefaultRubricV0 : DefaultRubric; + public static string GetSystemPrompt(bool useV0) => useV0 ? SystemPromptV0 : SystemPrompt; + public static string BuildUserPrompt( string schemaJson, string dataJson, string attachmentsJson, string rubric) { + return BuildUserPrompt(schemaJson, dataJson, attachmentsJson, rubric, useV0: false); + } + + public static string BuildUserPrompt( + string schemaJson, + string dataJson, + string attachmentsJson, + string rubric, + bool useV0) + { + var output = useV0 ? OutputTemplateV0 : OutputTemplate; + var rules = useV0 ? RulesV0 : Rules; + var severitySection = useV0 ? string.Empty : $@"SEVERITY +{SeverityRules} + +"; + return $@"SCHEMA {schemaJson} @@ -110,17 +179,14 @@ public static string BuildUserPrompt( RUBRIC {rubric} -SEVERITY -{SeverityRules} - -SCORE +{severitySection}SCORE {ScoreRules} OUTPUT -{OutputTemplate} +{output} RULES -{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 index 969480ea86..dd950b2065 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs @@ -6,10 +6,19 @@ internal static class AttachmentPrompts "You are a professional grant analyst for the BC Government.", "Produce a concise reviewer-facing summary of the provided attachment context."); + public static readonly string SystemPromptV0 = 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 OutputSectionV0 = @"OUTPUT +{ + ""summary"": """" +}"; + public const string RulesSection = @"RULES - Use only the provided attachment context as evidence. - If text content is present, summarize the actual content. @@ -18,7 +27,30 @@ internal static class AttachmentPrompts - Keep the summary specific, concrete, and reviewer-facing. - Return plain text only (no markdown, bullets, or JSON)."; + public const string RulesSectionV0 = "- Use only ATTACHMENT as evidence.\n" + + "- If ATTACHMENT.text is present, summarize actual content.\n" + + "- If ATTACHMENT.text is null or empty, provide a conservative file-level summary.\n" + + PromptCoreRules.NoInvention + "\n" + + @"- Write 1-2 complete sentences. +- Summary must be grounded in concrete ATTACHMENT evidence. +- Return exactly one object with only the key: summary. +" + + PromptCoreRules.MinimumNarrativeWords + "\n" + + PromptCoreRules.ExactOutputShape + "\n" + + PromptCoreRules.NoExtraOutputKeys + "\n" + + PromptCoreRules.ValidJsonOnly + "\n" + + PromptCoreRules.PlainJsonOnly; + + public static string GetSystemPrompt(bool useV0) => useV0 ? SystemPromptV0 : SystemPrompt; + public static string GetOutputSection(bool useV0) => useV0 ? OutputSectionV0 : OutputSection; + public static string GetRulesSection(bool useV0) => useV0 ? RulesSectionV0 : RulesSection; + public static string BuildUserPrompt(string attachmentPayloadJson) + { + return BuildUserPrompt(attachmentPayloadJson, useV0: false); + } + + public static string BuildUserPrompt(string attachmentPayloadJson, bool useV0) { return $@"ATTACHMENT {attachmentPayloadJson}"; 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 index 2db4de742d..306e43e360 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/ScoresheetPrompts.cs @@ -2,10 +2,41 @@ namespace Unity.GrantManager.AI { internal static class ScoresheetPrompts { + public static readonly string AllSystemPromptV0 = PromptHeader.Build( + "You are an expert grant application reviewer for the BC Government.", + "Using DATA, ATTACHMENTS, QUESTIONS, OUTPUT, and RULES, provide answers for all scoresheet questions."); + + public const string AllOutputTemplateV0 = @"{ + """": """" +}"; + + public const string AllRulesV0 = "- Use only DATA and ATTACHMENTS as evidence.\n" + + "- Do not invent missing application details.\n" + + @"- Return exactly one answer per question ID in QUESTIONS. +- Do not omit any question IDs from QUESTIONS. +- Do not add keys that are not question IDs from QUESTIONS. +- The ""answer"" value type must match the question type. +- For numeric questions, return a numeric value within the allowed range. +- For yes/no questions, return exactly ""Yes"" or ""No"". +- For select list questions, return only the selected options.number as a string and never return option label text. +- For text and text area questions, return concise, evidence-based text. +- For text and text area questions, include concise source-grounded rationale from the provided input content. +- If explicit evidence is insufficient, choose the most conservative valid answer. +" + + PromptCoreRules.MinimumNarrativeWords + "\n" + + PromptCoreRules.ExactOutputShape + "\n" + + PromptCoreRules.NoExtraOutputKeys + "\n" + + PromptCoreRules.ValidJsonOnly + "\n" + + PromptCoreRules.PlainJsonOnly; + 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 static readonly string SectionSystemPromptV0 = 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"": """", @@ -52,11 +83,23 @@ internal static class ScoresheetPrompts + PromptCoreRules.ValidJsonOnly + "\n" + PromptCoreRules.PlainJsonOnly; + public static string GetSectionSystemPrompt(bool useV0) => useV0 ? SectionSystemPromptV0 : SectionSystemPrompt; + public static string BuildSectionUserPrompt( string applicationContent, string attachmentSummariesText, string sectionPayloadJson, string responseTemplateJson) + { + return BuildSectionUserPrompt(applicationContent, attachmentSummariesText, sectionPayloadJson, responseTemplateJson, useV0: false); + } + + public static string BuildSectionUserPrompt( + string applicationContent, + string attachmentSummariesText, + string sectionPayloadJson, + string responseTemplateJson, + bool useV0) { return $@"DATA {applicationContent} From 5c2cb7681228bbfdb4f8b84185d06083ae7c49c2 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 6 Mar 2026 17:27:49 -0800 Subject: [PATCH 012/245] AB#31677 mq integration portal --- .../GrantManagerApplicationModule.cs | 19 ++ .../GrantsPortalRabbitMqOptions.cs | 22 ++ .../GrantsPortalAcknowledgmentPublisher.cs | 59 ++++ .../GrantsPortalCommandConsumerService.cs | 284 ++++++++++++++++++ .../GrantsPortalInboxProcessorService.cs | 223 ++++++++++++++ .../GrantsPortalMessageCleanupService.cs | 70 +++++ .../GrantsPortalOutboxProcessorService.cs | 192 ++++++++++++ .../Handlers/AddressEditHandler.cs | 55 ++++ .../Handlers/AddressSetPrimaryHandler.cs | 32 ++ .../Handlers/ContactCreateHandler.cs | 69 +++++ .../Handlers/ContactDeleteHandler.cs | 37 +++ .../Handlers/ContactEditHandler.cs | 43 +++ .../Handlers/ContactSetPrimaryHandler.cs | 40 +++ .../Handlers/IPortalCommandHandler.cs | 10 + .../Handlers/OrganizationEditHandler.cs | 50 +++ .../Messages/Commands/AddressEditData.cs | 33 ++ .../Messages/Commands/ContactCreateData.cs | 36 +++ .../Messages/Commands/ContactEditData.cs | 36 +++ .../Messages/Commands/OrganizationEditData.cs | 30 ++ .../Messages/MessageAcknowledgment.cs | 34 +++ .../Messages/PluginDataEnvelope.cs | 29 ++ .../Messages/PluginDataPayload.cs | 28 ++ .../Messaging/IInboxMessageRepository.cs | 13 + .../Messaging/IOutboxMessageRepository.cs | 12 + .../Messaging/InboxMessage.cs | 71 +++++ .../Messaging/OutboxMessage.cs | 68 +++++ .../Unity.GrantManager.Domain.csproj | 4 + .../GrantManagerDbContext.cs | 50 ++- .../Repositories/InboxMessageRepository.cs | 43 +++ .../Repositories/OutboxMessageRepository.cs | 37 +++ .../Repositories/PortalMessageRepository.cs | 0 .../Unity.GrantManager.Web/appsettings.json | 10 +- 32 files changed, 1737 insertions(+), 2 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs index 0f864af8e3..8aaefd869f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantManagerApplicationModule.cs @@ -41,6 +41,9 @@ using Unity.GrantManager.Integrations.Chefs; using Unity.Modules.Shared.Http; using Unity.GrantManager.Integrations.Geocoder; +using Unity.GrantManager.GrantsPortal; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Handlers; namespace Unity.GrantManager; @@ -147,6 +150,22 @@ public override void ConfigureServices(ServiceConfigurationContext context) } context.Services.ConfigureRabbitMQ(); + + // Grants Applicant Portal RabbitMQ integration + context.Services.Configure(configuration.GetSection(GrantsPortalRabbitMqOptions.SectionName)); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddTransient(); + context.Services.AddScoped(); + context.Services.AddHostedService(); // RabbitMQ → inbox table + context.Services.AddHostedService(); // inbox table → process → outbox table + context.Services.AddHostedService(); // outbox table → RabbitMQ + context.Services.AddHostedService(); // purge old processed messages + context.Services.AddScoped(); context.Services.AddSingleton(provider => diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs new file mode 100644 index 0000000000..26fd4799d2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Configuration/GrantsPortalRabbitMqOptions.cs @@ -0,0 +1,22 @@ +namespace Unity.GrantManager.GrantsPortal.Configuration; + +public class GrantsPortalRabbitMqOptions +{ + public const string SectionName = "RabbitMQ:GrantsPortal"; + + /// + /// The integration source identifier used in the IntegrationMessages table. + /// + public const string SourceName = "GrantsPortal"; + + public string Exchange { get; set; } = "grants.messaging"; + public string ExchangeType { get; set; } = "topic"; + public string InboundQueue { get; set; } = "unity.commands"; + public string[] InboundRoutingKeys { get; set; } = ["commands.unity.plugindata"]; + public string AckRoutingKey { get; set; } = "grants.unity.acknowledgment"; + + /// + /// Number of days to retain processed/failed messages before cleanup. + /// + public int MessageRetentionDays { get; set; } = 30; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs new file mode 100644 index 0000000000..c3aeda6258 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalAcknowledgmentPublisher.cs @@ -0,0 +1,59 @@ +using System; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using RabbitMQ.Client; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; + +namespace Unity.GrantManager.GrantsPortal; + +public class GrantsPortalAcknowledgmentPublisher( + IOptions options, + ILogger logger) +{ + private readonly GrantsPortalRabbitMqOptions _options = options.Value; + private static readonly JsonSerializerSettings s_jsonSettings = new() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }; + + public void Publish(IModel channel, string originalMessageId, string correlationId, string status, string details) + { + var ack = new MessageAcknowledgment + { + MessageId = Guid.NewGuid().ToString(), + OriginalMessageId = originalMessageId, + CorrelationId = correlationId, + Status = status, + Details = details, + CreatedAt = DateTime.UtcNow, + ProcessedAt = DateTime.UtcNow + }; + + var json = JsonConvert.SerializeObject(ack, s_jsonSettings); + var body = Encoding.UTF8.GetBytes(json); + + var properties = channel.CreateBasicProperties(); + properties.Type = "MessageAcknowledgment"; + properties.ContentType = "application/json"; + properties.ContentEncoding = "utf-8"; + properties.Persistent = true; + properties.MessageId = ack.MessageId; + properties.CorrelationId = correlationId; + properties.Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + channel.BasicPublish( + exchange: _options.Exchange, + routingKey: _options.AckRoutingKey, + basicProperties: properties, + body: body); + + logger.LogInformation( + "Published {Status} acknowledgment for message {OriginalMessageId} with ack id {AckMessageId}", + status, originalMessageId, ack.MessageId); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs new file mode 100644 index 0000000000..7631fad188 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalCommandConsumerService.cs @@ -0,0 +1,284 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.Messaging; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Pulls messages off the RabbitMQ queue, saves them to the inbox table, and ACKs immediately. +/// Actual processing is done by . +/// +public class GrantsPortalCommandConsumerService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IAsyncConnectionFactory _connectionFactory; + private readonly GrantsPortalRabbitMqOptions _options; + private readonly ILogger _logger; + + private IConnection? _connection; + private IModel? _channel; + + private const int MaxRetries = 5; + private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); + private CancellationToken _stoppingToken; + + public GrantsPortalCommandConsumerService( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options, + ILogger logger) + { + _serviceProvider = serviceProvider; + _connectionFactory = connectionFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _stoppingToken = stoppingToken; + _logger.LogInformation("Grants Portal command consumer starting..."); + + await ConnectAndConsumeAsync(stoppingToken); + + // Keep the service alive until cancellation + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Grants Portal command consumer stopping..."); + } + } + + private async Task ConnectAndConsumeAsync(CancellationToken cancellationToken) + { + for (int attempt = 1; attempt <= MaxRetries; attempt++) + { + try + { + _logger.LogInformation("Connecting to RabbitMQ for Grants Portal consumer (attempt {Attempt}/{MaxRetries})", attempt, MaxRetries); + + _connection = _connectionFactory.CreateConnection(); + _connection.ConnectionShutdown += OnConnectionShutdown; + _channel = _connection.CreateModel(); + _channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false); + + DeclareTopology(); + StartConsuming(); + + _logger.LogInformation("Grants Portal command consumer started. Listening on queue {Queue}", _options.InboundQueue); + return; + } + catch (Exception ex) when (attempt < MaxRetries) + { + var delay = TimeSpan.FromSeconds(InitialRetryDelay.TotalSeconds * Math.Pow(2, attempt - 1)); + _logger.LogWarning(ex, "Failed to connect to RabbitMQ (attempt {Attempt}). Retrying in {Delay}s...", attempt, delay.TotalSeconds); + await Task.Delay(delay, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to connect to RabbitMQ after {MaxRetries} attempts", MaxRetries); + throw; + } + } + } + + private void OnConnectionShutdown(object? sender, ShutdownEventArgs e) + { + if (_stoppingToken.IsCancellationRequested) return; + + _logger.LogWarning("RabbitMQ connection lost: {Reason}. Attempting to reconnect...", e.ReplyText); + + _ = Task.Run(async () => + { + await Task.Delay(InitialRetryDelay, _stoppingToken); + try + { + CleanupConnection(); + await ConnectAndConsumeAsync(_stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reconnect to RabbitMQ after connection loss"); + } + }, _stoppingToken); + } + + private void DeclareTopology() + { + if (_channel == null) return; + + _channel.ExchangeDeclare( + exchange: _options.Exchange, + type: _options.ExchangeType, + durable: true, + autoDelete: false); + + _channel.QueueDeclare( + queue: _options.InboundQueue, + durable: true, + exclusive: false, + autoDelete: false); + + foreach (var routingKey in _options.InboundRoutingKeys) + { + _channel.QueueBind( + queue: _options.InboundQueue, + exchange: _options.Exchange, + routingKey: routingKey); + } + + _logger.LogInformation( + "Declared exchange {Exchange} (topic), queue {Queue}, bound with routing keys [{RoutingKeys}]", + _options.Exchange, _options.InboundQueue, string.Join(", ", _options.InboundRoutingKeys)); + } + + private void StartConsuming() + { + if (_channel == null) return; + + var consumer = new AsyncEventingBasicConsumer(_channel); + consumer.Received += OnMessageReceivedAsync; + + _channel.BasicConsume( + queue: _options.InboundQueue, + autoAck: false, + consumer: consumer); + } + + /// + /// Receives a message from RabbitMQ and saves it to the inbox table. + /// The message is ACKed immediately after saving — no processing happens here. + /// + private async Task OnMessageReceivedAsync(object sender, BasicDeliverEventArgs ea) + { + var messageId = ea.BasicProperties?.MessageId ?? string.Empty; + var messageType = ea.BasicProperties?.Type ?? string.Empty; + var correlationId = ea.BasicProperties?.CorrelationId ?? string.Empty; + var consumingChannel = ((AsyncEventingBasicConsumer)sender).Model; + + _logger.LogInformation("Received message {MessageId} type={MessageType}", messageId, messageType); + + // Guard: discard acknowledgment messages to prevent infinite loops (spec §4.2) + if (string.Equals(messageType, "MessageAcknowledgment", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Discarding acknowledgment message {MessageId} to prevent loop", messageId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + try + { + var json = Encoding.UTF8.GetString(ea.Body.ToArray()); + var envelope = JsonConvert.DeserializeObject(json); + + if (envelope == null) + { + _logger.LogError("Failed to deserialize message {MessageId}. Discarding.", messageId); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + // Use envelope values as fallback for AMQP properties + if (string.IsNullOrEmpty(messageId)) messageId = envelope.MessageId; + if (string.IsNullOrEmpty(correlationId)) correlationId = envelope.CorrelationId; + + // Resolve tenant from the data.provider field (stored for later use by processors) + var payload = envelope.Data?.ToObject(); + var tenantId = ResolveTenantId(payload?.Provider); + + // Save to the central host inbox — no tenant context needed + using var scope = _serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + + // Idempotency: skip if we already have this message + var existing = await inboxRepo.FindByMessageIdAsync(messageId); + if (existing != null) + { + _logger.LogInformation("Message {MessageId} already in inbox (status={Status}). Skipping.", messageId, existing.Status); + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + var inboxMessage = new InboxMessage + { + Source = GrantsPortalRabbitMqOptions.SourceName, + MessageId = messageId, + CorrelationId = correlationId, + DataType = envelope.DataType, + Payload = json, + Status = MessageStatus.Pending, + ReceivedAt = DateTime.UtcNow, + TenantId = tenantId + }; + + await inboxRepo.InsertAsync(inboxMessage, autoSave: true); + await uow.CompleteAsync(); + + _logger.LogInformation("Message {MessageId} saved to inbox for processing", messageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving message {MessageId} to inbox. Message will be requeued.", messageId); + consumingChannel.BasicReject(ea.DeliveryTag, requeue: true); + return; + } + + // ACK only after successful save to inbox + consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + } + + private static Guid? ResolveTenantId(string? provider) + { + if (string.IsNullOrWhiteSpace(provider)) + return null; + + if (Guid.TryParse(provider, out var tenantGuid)) + return tenantGuid; + + return null; + } + + private void CleanupConnection() + { + try + { + if (_connection != null) _connection.ConnectionShutdown -= OnConnectionShutdown; + _channel?.Close(); + _channel?.Dispose(); + _connection?.Close(); + _connection?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error during connection cleanup"); + } + + _channel = null; + _connection = null; + } + + public override void Dispose() + { + CleanupConnection(); + base.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs new file mode 100644 index 0000000000..f4d805c175 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalInboxProcessorService.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.GrantsPortal.Handlers; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.Messaging; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central inbox table for pending inbound messages and processes them sequentially. +/// Switches to the correct tenant context only when executing the handler (domain operations). +/// On completion (success or failure), writes an outbound ack message to the same central table. +/// +public class GrantsPortalInboxProcessorService( + IServiceProvider serviceProvider, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(15); + private const int MaxRetryCount = 3; + + private static readonly Dictionary s_userFriendlyErrors = new(StringComparer.OrdinalIgnoreCase) + { + { "EntityNotFoundException", "The requested record was not found. It may have been deleted." }, + { "DbUpdateConcurrencyException", "The record was modified by another process. Please try again." }, + { "AbpDbConcurrencyException", "The record was modified by another process. Please try again." } + }; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Grants Portal inbox processor starting..."); + + // Wait for the application to fully start + await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var processedAny = await ProcessPendingMessagesAsync(stoppingToken); + var delay = processedAny ? PollingInterval : IdleInterval; + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error in inbox processor loop"); + await Task.Delay(IdleInterval, stoppingToken); + } + } + + logger.LogInformation("Grants Portal inbox processor stopped."); + } + + private async Task ProcessPendingMessagesAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + List pendingMessages; + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + pendingMessages = await inboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); + await uow.CompleteAsync(); + } + + if (pendingMessages.Count == 0) return false; + + foreach (var inboxMsg in pendingMessages) + { + if (cancellationToken.IsCancellationRequested) break; + + await ProcessSingleMessageAsync(scope, inboxMsg); + } + + return true; + } + + private async Task ProcessSingleMessageAsync(IServiceScope scope, InboxMessage inboxMsg) + { + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + var currentTenant = scope.ServiceProvider.GetRequiredService(); + var handlers = scope.ServiceProvider.GetServices(); + + logger.LogInformation("Processing inbox message {MessageId} (dataType={DataType}, tenantId={TenantId})", + inboxMsg.MessageId, inboxMsg.DataType, inboxMsg.TenantId); + + string ackStatus; + string details; + + try + { + // Mark as processing + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + inboxMsg.Status = MessageStatus.Processing; + inboxMsg.RetryCount++; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + + // Deserialize the payload + var envelope = JsonConvert.DeserializeObject(inboxMsg.Payload) + ?? throw new JsonException("Failed to deserialize message payload"); + + var payload = envelope.Data?.ToObject() + ?? throw new ArgumentException("Message data payload is missing"); + + var handler = handlers.FirstOrDefault(h => + string.Equals(h.DataType, inboxMsg.DataType, StringComparison.OrdinalIgnoreCase)); + + if (handler == null) + { + ackStatus = "FAILED"; + details = $"Unknown command type: {inboxMsg.DataType}"; + logger.LogWarning("No handler registered for dataType {DataType}", inboxMsg.DataType); + } + else + { + // Switch to tenant context ONLY for the domain handler execution + using (currentTenant.Change(inboxMsg.TenantId)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + details = await handler.HandleAsync(payload); + await uow.CompleteAsync(); + } + ackStatus = "SUCCESS"; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Error processing inbox message {MessageId}", inboxMsg.MessageId); + ackStatus = "FAILED"; + details = ToUserFriendlyMessage(ex); + + // Check if we should retry + if (inboxMsg.RetryCount < MaxRetryCount && IsTransientError(ex)) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + inboxMsg.Status = MessageStatus.Pending; + inboxMsg.Details = details; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + await uow.CompleteAsync(); + logger.LogInformation("Message {MessageId} will be retried (attempt {Attempt}/{MaxRetries})", + inboxMsg.MessageId, inboxMsg.RetryCount, MaxRetryCount); + return; + } + } + + // Mark inbox as complete + write to outbox — same transaction + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + inboxMsg.Status = ackStatus == "SUCCESS" ? MessageStatus.Processed : MessageStatus.Failed; + inboxMsg.Details = details; + inboxMsg.ProcessedAt = DateTime.UtcNow; + await inboxRepo.UpdateAsync(inboxMsg, autoSave: true); + + var outboxMsg = new OutboxMessage + { + Source = GrantsPortalRabbitMqOptions.SourceName, + MessageId = Guid.NewGuid().ToString(), + OriginalMessageId = inboxMsg.MessageId, + CorrelationId = inboxMsg.CorrelationId, + DataType = inboxMsg.DataType, + AckStatus = ackStatus, + Details = details, + Status = MessageStatus.Pending, + CreatedAt = DateTime.UtcNow, + TenantId = inboxMsg.TenantId + }; + + await outboxRepo.InsertAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + + logger.LogInformation("Inbox message {MessageId} processed with status {Status}", + inboxMsg.MessageId, ackStatus); + } + + private static string ToUserFriendlyMessage(Exception ex) + { + var exType = ex.GetType().Name; + + if (s_userFriendlyErrors.TryGetValue(exType, out var friendly)) + return friendly; + + // Check inner exception type + if (ex.InnerException != null) + { + var innerType = ex.InnerException.GetType().Name; + if (s_userFriendlyErrors.TryGetValue(innerType, out var innerFriendly)) + return innerFriendly; + } + + // For unrecognized exceptions, return a generic message — never leak stack traces + return "An unexpected error occurred while processing your request. Please try again or contact support."; + } + + private static bool IsTransientError(Exception ex) + { + var typeName = ex.GetType().Name; + return typeName.Contains("Timeout", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Concurrency", StringComparison.OrdinalIgnoreCase) + || typeName.Contains("Transient", StringComparison.OrdinalIgnoreCase) + || ex.InnerException is TimeoutException; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs new file mode 100644 index 0000000000..ef8915cc20 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalMessageCleanupService.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Periodically deletes processed/failed messages older than the configured retention period. +/// Runs once per hour against the central host database. +/// +public class GrantsPortalMessageCleanupService( + IServiceProvider serviceProvider, + IOptions options, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan CleanupInterval = TimeSpan.FromHours(1); + private readonly int _retentionDays = options.Value.MessageRetentionDays; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Integration message cleanup service starting (retention={RetentionDays} days)", _retentionDays); + + // Wait for the application to fully start + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CleanupOldMessagesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error during integration message cleanup"); + } + + await Task.Delay(CleanupInterval, stoppingToken); + } + } + + private async Task CleanupOldMessagesAsync() + { + var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); + + using var scope = serviceProvider.CreateScope(); + var inboxRepo = scope.ServiceProvider.GetRequiredService(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + var inboxDeleted = await inboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + var outboxDeleted = await outboxRepo.DeleteProcessedOlderThanAsync(cutoffDate); + await uow.CompleteAsync(); + + var total = inboxDeleted + outboxDeleted; + if (total > 0) + { + logger.LogInformation( + "Cleaned up {Total} messages older than {CutoffDate:yyyy-MM-dd} (inbox={InboxCount}, outbox={OutboxCount})", + total, cutoffDate, inboxDeleted, outboxDeleted); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs new file mode 100644 index 0000000000..566e378535 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/GrantsPortalOutboxProcessorService.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using Unity.GrantManager.GrantsPortal.Configuration; +using Unity.GrantManager.Messaging; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal; + +/// +/// Polls the central outbox table for pending acknowledgment messages and publishes them to RabbitMQ. +/// Uses publisher confirms to ensure delivery before marking messages as sent. +/// No tenant context needed — the outbox table is in the host database. +/// +public class GrantsPortalOutboxProcessorService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IAsyncConnectionFactory _connectionFactory; + private readonly GrantsPortalRabbitMqOptions _options; + private readonly ILogger _logger; + + private IConnection? _connection; + private IModel? _channel; + + private static readonly TimeSpan PollingInterval = TimeSpan.FromSeconds(5); + private static readonly TimeSpan IdleInterval = TimeSpan.FromSeconds(15); + private const int MaxPublishRetries = 3; + + public GrantsPortalOutboxProcessorService( + IServiceProvider serviceProvider, + IAsyncConnectionFactory connectionFactory, + IOptions options, + ILogger logger) + { + _serviceProvider = serviceProvider; + _connectionFactory = connectionFactory; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Grants Portal outbox processor starting..."); + + // Wait for the application to fully start + await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + EnsureChannel(); + var processedAny = await PublishPendingAcksAsync(stoppingToken); + var delay = processedAny ? PollingInterval : IdleInterval; + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in outbox processor loop. Will retry after delay."); + CleanupChannel(); + await Task.Delay(IdleInterval, stoppingToken); + } + } + + _logger.LogInformation("Grants Portal outbox processor stopped."); + } + + private void EnsureChannel() + { + if (_channel is { IsOpen: true }) return; + + CleanupChannel(); + + _connection = _connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + _channel.ConfirmSelect(); + + _logger.LogInformation("Outbox processor RabbitMQ channel established"); + } + + private async Task PublishPendingAcksAsync(CancellationToken cancellationToken) + { + using var scope = _serviceProvider.CreateScope(); + var outboxRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWorkManager = scope.ServiceProvider.GetRequiredService(); + + List pendingMessages; + using (var uow = unitOfWorkManager.Begin(requiresNew: true)) + { + pendingMessages = await outboxRepo.GetPendingAsync(GrantsPortalRabbitMqOptions.SourceName, 10); + await uow.CompleteAsync(); + } + + if (pendingMessages.Count == 0) return false; + + foreach (var outboxMsg in pendingMessages) + { + if (cancellationToken.IsCancellationRequested) break; + + await PublishSingleAckAsync(outboxMsg, outboxRepo, unitOfWorkManager); + } + + return true; + } + + private async Task PublishSingleAckAsync( + OutboxMessage outboxMsg, + IOutboxMessageRepository outboxRepo, + IUnitOfWorkManager unitOfWorkManager) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var publisher = scope.ServiceProvider.GetRequiredService(); + + publisher.Publish( + _channel!, + outboxMsg.OriginalMessageId, + outboxMsg.CorrelationId, + outboxMsg.AckStatus, + outboxMsg.Details); + + // Wait for broker to confirm + if (!_channel!.WaitForConfirms(TimeSpan.FromSeconds(5))) + { + throw new InvalidOperationException("Broker did not confirm ack publish"); + } + + // Mark as sent + using var uow = unitOfWorkManager.Begin(requiresNew: true); + outboxMsg.Status = MessageStatus.Processed; + outboxMsg.PublishedAt = DateTime.UtcNow; + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + + _logger.LogInformation("Outbox message {MessageId} published (ack for {OriginalMessageId})", + outboxMsg.MessageId, outboxMsg.OriginalMessageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to publish outbox message {MessageId}", outboxMsg.MessageId); + + outboxMsg.RetryCount++; + if (outboxMsg.RetryCount >= MaxPublishRetries) + { + outboxMsg.Status = MessageStatus.Failed; + outboxMsg.Details = $"Failed to publish after {MaxPublishRetries} attempts: {ex.Message}"; + _logger.LogError("Outbox message {MessageId} marked as failed after {MaxRetries} publish attempts", + outboxMsg.MessageId, MaxPublishRetries); + } + + using var uow = unitOfWorkManager.Begin(requiresNew: true); + await outboxRepo.UpdateAsync(outboxMsg, autoSave: true); + await uow.CompleteAsync(); + } + } + + private void CleanupChannel() + { + try + { + _channel?.Close(); + _channel?.Dispose(); + _connection?.Close(); + _connection?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error during outbox channel cleanup"); + } + + _channel = null; + _connection = null; + } + + public override void Dispose() + { + CleanupChannel(); + base.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs new file mode 100644 index 0000000000..96df0b8d0b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressEditHandler.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantApplications; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressEditHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var addressId = Guid.Parse(payload.AddressId ?? throw new ArgumentException("addressId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Address data is required"); + + logger.LogInformation("Editing address {AddressId} for profile {ProfileId}", addressId, payload.ProfileId); + + var address = await applicantAddressRepository.GetAsync(addressId); + + address.Street = innerData.Street; + address.Street2 = innerData.Street2; + address.Unit = innerData.Unit; + address.City = innerData.City; + address.Province = innerData.Province; + address.Postal = innerData.PostalCode; + address.Country = innerData.Country; + address.AddressType = MapAddressType(innerData.AddressType); + + await applicantAddressRepository.UpdateAsync(address, autoSave: true); + + logger.LogInformation("Address {AddressId} updated successfully", addressId); + return "Address updated successfully"; + } + + private static GrantApplications.AddressType MapAddressType(string? portalAddressType) + { + return portalAddressType?.ToUpperInvariant() switch + { + "MAILING" => GrantApplications.AddressType.MailingAddress, + "PHYSICAL" => GrantApplications.AddressType.PhysicalAddress, + "BUSINESS" => GrantApplications.AddressType.BusinessAddress, + _ => GrantApplications.AddressType.PhysicalAddress + }; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs new file mode 100644 index 0000000000..c6e3efe2eb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/AddressSetPrimaryHandler.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class AddressSetPrimaryHandler( + IApplicantAddressRepository applicantAddressRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ADDRESS_SET_PRIMARY_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var addressId = Guid.Parse(payload.AddressId ?? throw new ArgumentException("addressId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); + + logger.LogInformation("Setting address {AddressId} as primary for profile {ProfileId}", addressId, profileId); + + // TODO: Implement set-primary logic once the primary address tracking mechanism is confirmed. + // The ApplicantAddress entity does not currently have an IsPrimary field. + // This may require updating sibling addresses for the same applicant. + + logger.LogInformation("Address {AddressId} set as primary", addressId); + return "Address set as primary"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs new file mode 100644 index 0000000000..8a24a76415 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactCreateHandler.cs @@ -0,0 +1,69 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactCreateHandler( + IContactRepository contactRepository, + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_CREATE_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Contact data is required"); + + // Idempotency: if the contact already exists, treat as success + var existing = await contactRepository.FindAsync(contactId); + if (existing != null) + { + logger.LogInformation("Contact {ContactId} already exists. Treating as idempotent success.", contactId); + return "Contact already exists"; + } + + logger.LogInformation("Creating contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + var contact = new Contact + { + Name = innerData.Name, + Email = innerData.Email, + Title = innerData.Title, + HomePhoneNumber = innerData.HomePhoneNumber, + MobilePhoneNumber = innerData.MobilePhoneNumber, + WorkPhoneNumber = innerData.WorkPhoneNumber, + WorkPhoneExtension = innerData.WorkPhoneExtension + }; + + EntityHelper.TrySetId(contact, () => contactId); + + await contactRepository.InsertAsync(contact, autoSave: true); + + // Create a contact link to track the relationship and primary status + var contactLink = new ContactLink + { + ContactId = contactId, + RelatedEntityType = innerData.ContactType ?? "PORTAL", + RelatedEntityId = Guid.Parse(payload.ProfileId ?? Guid.Empty.ToString()), + Role = innerData.Role, + IsPrimary = innerData.IsPrimary, + IsActive = true + }; + + await contactLinkRepository.InsertAsync(contactLink, autoSave: true); + + logger.LogInformation("Contact {ContactId} created successfully", contactId); + return "Contact created successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs new file mode 100644 index 0000000000..128f5a5e10 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactDeleteHandler.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactDeleteHandler( + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_DELETE_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + + logger.LogInformation("Deleting (deactivating) contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + // Soft-delete by deactivating contact links + var links = await contactLinkRepository.GetListAsync(cl => cl.ContactId == contactId && cl.IsActive); + + foreach (var link in links) + { + link.IsActive = false; + await contactLinkRepository.UpdateAsync(link, autoSave: true); + } + + logger.LogInformation("Contact {ContactId} deactivated successfully", contactId); + return "Contact deleted successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs new file mode 100644 index 0000000000..b3fcdeb129 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactEditHandler.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactEditHandler( + IContactRepository contactRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Contact data is required"); + + logger.LogInformation("Editing contact {ContactId} for profile {ProfileId}", contactId, payload.ProfileId); + + var contact = await contactRepository.GetAsync(contactId); + + contact.Name = innerData.Name; + contact.Email = innerData.Email; + contact.Title = innerData.Title; + contact.HomePhoneNumber = innerData.HomePhoneNumber; + contact.MobilePhoneNumber = innerData.MobilePhoneNumber; + contact.WorkPhoneNumber = innerData.WorkPhoneNumber; + contact.WorkPhoneExtension = innerData.WorkPhoneExtension; + + await contactRepository.UpdateAsync(contact, autoSave: true); + + logger.LogInformation("Contact {ContactId} updated successfully", contactId); + return "Contact updated successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs new file mode 100644 index 0000000000..18a49cb974 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/ContactSetPrimaryHandler.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Contacts; +using Unity.GrantManager.GrantsPortal.Messages; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class ContactSetPrimaryHandler( + IContactLinkRepository contactLinkRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "CONTACT_SET_PRIMARY_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var contactId = Guid.Parse(payload.ContactId ?? throw new ArgumentException("contactId is required")); + var profileId = Guid.Parse(payload.ProfileId ?? throw new ArgumentException("profileId is required")); + + logger.LogInformation("Setting contact {ContactId} as primary for profile {ProfileId}", contactId, profileId); + + // Find all contact links for this profile and clear their primary flag + var profileLinks = await contactLinkRepository.GetListAsync( + cl => cl.RelatedEntityId == profileId && cl.IsActive); + + foreach (var link in profileLinks) + { + link.IsPrimary = link.ContactId == contactId; + await contactLinkRepository.UpdateAsync(link, autoSave: true); + } + + logger.LogInformation("Contact {ContactId} set as primary", contactId); + return "Contact set as primary"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs new file mode 100644 index 0000000000..88925c7ee2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/IPortalCommandHandler.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Unity.GrantManager.GrantsPortal.Messages; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public interface IPortalCommandHandler +{ + string DataType { get; } + Task HandleAsync(PluginDataPayload payload); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs new file mode 100644 index 0000000000..87b1306ac0 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Handlers/OrganizationEditHandler.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Unity.GrantManager.Applications; +using Unity.GrantManager.GrantsPortal.Messages; +using Unity.GrantManager.GrantsPortal.Messages.Commands; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.Uow; + +namespace Unity.GrantManager.GrantsPortal.Handlers; + +public class OrganizationEditHandler( + IApplicantRepository applicantRepository, + ILogger logger) : IPortalCommandHandler, ITransientDependency +{ + public string DataType => "ORGANIZATION_EDIT_COMMAND"; + + [UnitOfWork] + public virtual async Task HandleAsync(PluginDataPayload payload) + { + var innerData = payload.Data?.ToObject() + ?? throw new ArgumentException("Organization data is required"); + + logger.LogInformation("Editing organization for profile {ProfileId}", payload.ProfileId); + + // TODO: Determine the correct lookup strategy for the Applicant entity. + // For now, use organizationId from the payload as a direct Applicant ID. + var organizationId = Guid.Parse(payload.OrganizationId ?? throw new ArgumentException("organizationId is required")); + var applicant = await applicantRepository.GetAsync(organizationId); + + applicant.OrgName = innerData.Name; + applicant.OrganizationType = innerData.OrganizationType; + applicant.OrgNumber = innerData.OrganizationNumber; + applicant.OrgStatus = innerData.Status; + applicant.NonRegOrgName = innerData.NonRegOrgName; + applicant.FiscalMonth = innerData.FiscalMonth; + applicant.OrganizationSize = innerData.OrganizationSize; + + if (int.TryParse(innerData.FiscalDay, out var fiscalDay)) + { + applicant.FiscalDay = fiscalDay; + } + + await applicantRepository.UpdateAsync(applicant, autoSave: true); + + logger.LogInformation("Organization {OrganizationId} updated successfully", organizationId); + return "Organization updated successfully"; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs new file mode 100644 index 0000000000..3d6c655fb4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/AddressEditData.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class AddressEditData +{ + [JsonProperty("addressType")] + public string? AddressType { get; set; } + + [JsonProperty("street")] + public string Street { get; set; } = string.Empty; + + [JsonProperty("street2")] + public string? Street2 { get; set; } + + [JsonProperty("unit")] + public string? Unit { get; set; } + + [JsonProperty("city")] + public string City { get; set; } = string.Empty; + + [JsonProperty("province")] + public string Province { get; set; } = string.Empty; + + [JsonProperty("postalCode")] + public string PostalCode { get; set; } = string.Empty; + + [JsonProperty("country")] + public string? Country { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs new file mode 100644 index 0000000000..e89566c42d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactCreateData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class ContactCreateData +{ + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("email")] + public string Email { get; set; } = string.Empty; + + [JsonProperty("title")] + public string? Title { get; set; } + + [JsonProperty("contactType")] + public string? ContactType { get; set; } + + [JsonProperty("homePhoneNumber")] + public string? HomePhoneNumber { get; set; } + + [JsonProperty("mobilePhoneNumber")] + public string? MobilePhoneNumber { get; set; } + + [JsonProperty("workPhoneNumber")] + public string? WorkPhoneNumber { get; set; } + + [JsonProperty("workPhoneExtension")] + public string? WorkPhoneExtension { get; set; } + + [JsonProperty("role")] + public string? Role { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs new file mode 100644 index 0000000000..a4f4f5d605 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/ContactEditData.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class ContactEditData +{ + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("email")] + public string Email { get; set; } = string.Empty; + + [JsonProperty("title")] + public string? Title { get; set; } + + [JsonProperty("contactType")] + public string? ContactType { get; set; } + + [JsonProperty("homePhoneNumber")] + public string? HomePhoneNumber { get; set; } + + [JsonProperty("mobilePhoneNumber")] + public string? MobilePhoneNumber { get; set; } + + [JsonProperty("workPhoneNumber")] + public string? WorkPhoneNumber { get; set; } + + [JsonProperty("workPhoneExtension")] + public string? WorkPhoneExtension { get; set; } + + [JsonProperty("role")] + public string? Role { get; set; } + + [JsonProperty("isPrimary")] + public bool IsPrimary { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs new file mode 100644 index 0000000000..e2d08dc007 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/Commands/OrganizationEditData.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages.Commands; + +public class OrganizationEditData +{ + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("organizationType")] + public string? OrganizationType { get; set; } + + [JsonProperty("organizationNumber")] + public string? OrganizationNumber { get; set; } + + [JsonProperty("status")] + public string? Status { get; set; } + + [JsonProperty("nonRegOrgName")] + public string? NonRegOrgName { get; set; } + + [JsonProperty("fiscalMonth")] + public string? FiscalMonth { get; set; } + + [JsonProperty("fiscalDay")] + public string? FiscalDay { get; set; } + + [JsonProperty("organizationSize")] + public string? OrganizationSize { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs new file mode 100644 index 0000000000..fdb25805e7 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/MessageAcknowledgment.cs @@ -0,0 +1,34 @@ +using System; +using Newtonsoft.Json; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class MessageAcknowledgment +{ + [JsonProperty("messageId")] + public string MessageId { get; set; } = Guid.NewGuid().ToString(); + + [JsonProperty("messageType")] + public string MessageType { get; set; } = "MessageAcknowledgment"; + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + [JsonProperty("correlationId")] + public string CorrelationId { get; set; } = string.Empty; + + [JsonProperty("pluginId")] + public string PluginId { get; set; } = "UNITY"; + + [JsonProperty("originalMessageId")] + public string OriginalMessageId { get; set; } = string.Empty; + + [JsonProperty("status")] + public string Status { get; set; } = string.Empty; + + [JsonProperty("details")] + public string Details { get; set; } = string.Empty; + + [JsonProperty("processedAt")] + public DateTime ProcessedAt { get; set; } = DateTime.UtcNow; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs new file mode 100644 index 0000000000..926d80a067 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataEnvelope.cs @@ -0,0 +1,29 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class PluginDataEnvelope +{ + [JsonProperty("messageId")] + public string MessageId { get; set; } = string.Empty; + + [JsonProperty("messageType")] + public string MessageType { get; set; } = string.Empty; + + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("correlationId")] + public string CorrelationId { get; set; } = string.Empty; + + [JsonProperty("pluginId")] + public string PluginId { get; set; } = string.Empty; + + [JsonProperty("dataType")] + public string DataType { get; set; } = string.Empty; + + [JsonProperty("data")] + public JObject? Data { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs new file mode 100644 index 0000000000..f6109989a4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantsPortal/Messages/PluginDataPayload.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Unity.GrantManager.GrantsPortal.Messages; + +public class PluginDataPayload +{ + [JsonProperty("action")] + public string Action { get; set; } = string.Empty; + + [JsonProperty("contactId")] + public string? ContactId { get; set; } + + [JsonProperty("addressId")] + public string? AddressId { get; set; } + + [JsonProperty("organizationId")] + public string? OrganizationId { get; set; } + + [JsonProperty("profileId")] + public string? ProfileId { get; set; } + + [JsonProperty("provider")] + public string? Provider { get; set; } + + [JsonProperty("data")] + public JObject? Data { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs new file mode 100644 index 0000000000..795eef08fa --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IInboxMessageRepository.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.Messaging; + +public interface IInboxMessageRepository : IRepository +{ + Task FindByMessageIdAsync(string messageId); + Task> GetPendingAsync(string source, int maxCount = 10); + Task DeleteProcessedOlderThanAsync(DateTime cutoffDate); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs new file mode 100644 index 0000000000..01a1cd6740 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/IOutboxMessageRepository.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Unity.GrantManager.Messaging; + +public interface IOutboxMessageRepository : IRepository +{ + Task> GetPendingAsync(string source, int maxCount = 10); + Task DeleteProcessedOlderThanAsync(DateTime cutoffDate); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs new file mode 100644 index 0000000000..c748b75390 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/InboxMessage.cs @@ -0,0 +1,71 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Messaging; + +public enum MessageStatus +{ + Pending = 1, + Processing = 2, + Processed = 3, + Failed = 4 +} + +/// +/// A message received from an external system, stored for sequential processing. +/// +public class InboxMessage : AuditedAggregateRoot, IMultiTenant +{ + /// + /// Identifies the integration source (e.g. "GrantsPortal"). + /// + public string Source { get; set; } = string.Empty; + + /// + /// The message ID from the source system. Used for idempotency. + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// The correlation ID passed through from the source system. + /// + public string CorrelationId { get; set; } = string.Empty; + + /// + /// The command discriminator (e.g. CONTACT_CREATE_COMMAND). + /// + public string DataType { get; set; } = string.Empty; + + /// + /// The full JSON payload of the inbound message. + /// + public string Payload { get; set; } = string.Empty; + + /// + /// Current processing status. + /// + public MessageStatus Status { get; set; } = MessageStatus.Pending; + + /// + /// Human-readable details (processing result or error message). + /// + public string? Details { get; set; } + + /// + /// Number of processing attempts. + /// + public int RetryCount { get; set; } + + /// + /// When the message was received from the broker. + /// + public DateTime ReceivedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the message was successfully processed. + /// + public DateTime? ProcessedAt { get; set; } + + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs new file mode 100644 index 0000000000..56537ee16a --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Messaging/OutboxMessage.cs @@ -0,0 +1,68 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; +using Volo.Abp.MultiTenancy; + +namespace Unity.GrantManager.Messaging; + +/// +/// An acknowledgment or response message to be published to an external system. +/// +public class OutboxMessage : AuditedAggregateRoot, IMultiTenant +{ + /// + /// Identifies the integration target (e.g. "GrantsPortal"). + /// + public string Source { get; set; } = string.Empty; + + /// + /// A unique message ID for this outbound message. + /// + public string MessageId { get; set; } = string.Empty; + + /// + /// The message ID of the original inbound command this is responding to. + /// + public string OriginalMessageId { get; set; } = string.Empty; + + /// + /// The correlation ID passed through from the original inbound message. + /// + public string CorrelationId { get; set; } = string.Empty; + + /// + /// The data type of the original command (e.g. CONTACT_CREATE_COMMAND). + /// + public string DataType { get; set; } = string.Empty; + + /// + /// The acknowledgment status: SUCCESS, FAILED, or PROCESSING. + /// + public string AckStatus { get; set; } = string.Empty; + + /// + /// Human-readable details (shown to the Portal user on failure). + /// + public string Details { get; set; } = string.Empty; + + /// + /// Current publish status. + /// + public MessageStatus Status { get; set; } = MessageStatus.Pending; + + /// + /// Number of publish attempts. + /// + public int RetryCount { get; set; } + + /// + /// When the outbox message was created. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// When the message was successfully published to the broker. + /// + public DateTime? PublishedAt { get; set; } + + public Guid? TenantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj index 096adad348..5552818a00 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Unity.GrantManager.Domain.csproj @@ -33,4 +33,8 @@ + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 498c00a127..b7ad146b28 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -19,6 +19,8 @@ using AppAny.Quartz.EntityFrameworkCore.Migrations; using AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL; using Unity.GrantManager.Integrations; +using Unity.GrantManager.Messaging; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Unity.GrantManager.EntityFrameworkCore; @@ -42,6 +44,8 @@ public class GrantManagerDbContext : public DbSet RegionalDistricts { get; set; } public DbSet TenantTokens { get; set; } public DbSet Communities { get; set; } + public DbSet InboxMessages { get; set; } + public DbSet OutboxMessages { get; set; } #region Entities from the modules @@ -179,7 +183,51 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.HasIndex(x => x.OidcSubUsername); b.HasIndex(x => new { x.OidcSubUsername, x.TenantId }).IsUnique(); }); - + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "InboxMessages", + GrantManagerConsts.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Source).IsRequired().HasMaxLength(50); + b.Property(x => x.MessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DataType).IsRequired().HasMaxLength(100); + b.Property(x => x.Payload).IsRequired().HasColumnType("jsonb"); + b.Property(x => x.Details).HasMaxLength(2000); + + b.Property(x => x.Status) + .IsRequired() + .HasConversion(new EnumToStringConverter()); + + b.HasIndex(x => x.MessageId); + b.HasIndex(x => new { x.Source, x.Status }); + }); + + modelBuilder.Entity(b => + { + b.ToTable(GrantManagerConsts.DbTablePrefix + "OutboxMessages", + GrantManagerConsts.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Source).IsRequired().HasMaxLength(50); + b.Property(x => x.MessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.OriginalMessageId).IsRequired().HasMaxLength(64); + b.Property(x => x.CorrelationId).HasMaxLength(128); + b.Property(x => x.DataType).IsRequired().HasMaxLength(100); + b.Property(x => x.AckStatus).IsRequired().HasMaxLength(20); + b.Property(x => x.Details).HasMaxLength(2000); + + b.Property(x => x.Status) + .IsRequired() + .HasConversion(new EnumToStringConverter()); + + b.HasIndex(x => new { x.Source, x.Status }); + }); + var allEntityTypes = modelBuilder.Model.GetEntityTypes(); foreach (var type in allEntityTypes.Where(t => t.ClrType != typeof(ExtraPropertyDictionary)).Select(t => t.ClrType)) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs new file mode 100644 index 0000000000..2fe97f303f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/InboxMessageRepository.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.EntityFrameworkCore; +using Unity.GrantManager.Messaging; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IInboxMessageRepository))] +public class InboxMessageRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), IInboxMessageRepository +{ + public async Task FindByMessageIdAsync(string messageId) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.FirstOrDefaultAsync(m => m.MessageId == messageId); + } + + public async Task> GetPendingAsync(string source, int maxCount = 10) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(m => m.Source == source && m.Status == MessageStatus.Pending) + .OrderBy(m => m.ReceivedAt) + .Take(maxCount) + .ToListAsync(); + } + + public async Task DeleteProcessedOlderThanAsync(DateTime cutoffDate) + { + var dbContext = await GetDbContextAsync(); + return await dbContext.InboxMessages + .Where(m => (m.Status == MessageStatus.Processed || m.Status == MessageStatus.Failed) + && m.ReceivedAt < cutoffDate) + .ExecuteDeleteAsync(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs new file mode 100644 index 0000000000..ec533caba2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/OutboxMessageRepository.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Unity.GrantManager.EntityFrameworkCore; +using Unity.GrantManager.Messaging; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +namespace Unity.GrantManager.Repositories; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IOutboxMessageRepository))] +public class OutboxMessageRepository(IDbContextProvider dbContextProvider) + : EfCoreRepository(dbContextProvider), IOutboxMessageRepository +{ + public async Task> GetPendingAsync(string source, int maxCount = 10) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(m => m.Source == source && m.Status == MessageStatus.Pending) + .OrderBy(m => m.CreatedAt) + .Take(maxCount) + .ToListAsync(); + } + + public async Task DeleteProcessedOlderThanAsync(DateTime cutoffDate) + { + var dbContext = await GetDbContextAsync(); + return await dbContext.OutboxMessages + .Where(m => (m.Status == MessageStatus.Processed || m.Status == MessageStatus.Failed) + && m.CreatedAt < cutoffDate) + .ExecuteDeleteAsync(); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/PortalMessageRepository.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json index 7d5da8e4b4..eb29270cfa 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/appsettings.json @@ -20,7 +20,15 @@ "HostName": "127.0.0.1", "Port": 5672, "UserName": "guest", - "VirtualHost": "/" + "VirtualHost": "/", + "GrantsPortal": { + "Exchange": "grants.messaging", + "ExchangeType": "topic", + "InboundQueue": "unity.commands", + "InboundRoutingKeys": [ "commands.unity.plugindata" ], + "AckRoutingKey": "grants.unity.acknowledgment", + "MessageRetentionDays": 30 + } }, "Payments": { "CasBaseUrl": "https://:/ords/cas/", From 003dea3c108699aaa07ebd615529ecce3acde343 Mon Sep 17 00:00:00 2001 From: Andre Goncalves Date: Fri, 6 Mar 2026 17:32:58 -0800 Subject: [PATCH 013/245] AB#322238 fixing missing snapshot --- .../20260307012926_Fixsnaphot.Designer.cs | 2694 +++++++++++++++++ .../20260307012926_Fixsnaphot.cs | 21 + .../GrantManagerDbContextModelSnapshot.cs | 101 +- 3 files changed, 2800 insertions(+), 16 deletions(-) create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.Designer.cs create mode 100644 applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.cs diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.Designer.cs new file mode 100644 index 0000000000..bd71f649e4 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.Designer.cs @@ -0,0 +1,2694 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + [DbContext(typeof(GrantManagerDbContext))] + [Migration("20260307012926_Fixsnaphot")] + partial class Fixsnaphot + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BlobData") + .HasColumnType("bytea") + .HasColumnName("blob_data"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_blob_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCalendar", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Calendar") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("calendar"); + + b.HasKey("SchedulerName", "CalendarName"); + + b.ToTable("qrtz_calendars", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cron_expression"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_cron_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzFiredTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("EntryId") + .HasColumnType("text") + .HasColumnName("entry_id"); + + b.Property("FiredTime") + .HasColumnType("bigint") + .HasColumnName("fired_time"); + + b.Property("InstanceName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.Property("ScheduledTime") + .HasColumnType("bigint") + .HasColumnName("sched_time"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("TriggerGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("TriggerName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.HasKey("SchedulerName", "EntryId"); + + b.HasIndex("InstanceName") + .HasDatabaseName("idx_qrtz_ft_trig_inst_name"); + + b.HasIndex("JobGroup") + .HasDatabaseName("idx_qrtz_ft_job_group"); + + b.HasIndex("JobName") + .HasDatabaseName("idx_qrtz_ft_job_name"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_ft_job_req_recovery"); + + b.HasIndex("TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_group"); + + b.HasIndex("TriggerName") + .HasDatabaseName("idx_qrtz_ft_trig_name"); + + b.HasIndex("SchedulerName", "TriggerName", "TriggerGroup") + .HasDatabaseName("idx_qrtz_ft_trig_nm_gp"); + + b.ToTable("qrtz_fired_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("JobName") + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("JobGroup") + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IsDurable") + .HasColumnType("bool") + .HasColumnName("is_durable"); + + b.Property("IsNonConcurrent") + .HasColumnType("bool") + .HasColumnName("is_nonconcurrent"); + + b.Property("IsUpdateData") + .HasColumnType("bool") + .HasColumnName("is_update_data"); + + b.Property("JobClassName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_class_name"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("RequestsRecovery") + .HasColumnType("bool") + .HasColumnName("requests_recovery"); + + b.HasKey("SchedulerName", "JobName", "JobGroup"); + + b.HasIndex("RequestsRecovery") + .HasDatabaseName("idx_qrtz_j_req_recovery"); + + b.ToTable("qrtz_job_details", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzLock", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("LockName") + .HasColumnType("text") + .HasColumnName("lock_name"); + + b.HasKey("SchedulerName", "LockName"); + + b.ToTable("qrtz_locks", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzPausedTriggerGroup", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.HasKey("SchedulerName", "TriggerGroup"); + + b.ToTable("qrtz_paused_trigger_grps", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSchedulerState", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("InstanceName") + .HasColumnType("text") + .HasColumnName("instance_name"); + + b.Property("CheckInInterval") + .HasColumnType("bigint") + .HasColumnName("checkin_interval"); + + b.Property("LastCheckInTime") + .HasColumnType("bigint") + .HasColumnName("last_checkin_time"); + + b.HasKey("SchedulerName", "InstanceName"); + + b.ToTable("qrtz_scheduler_state", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("BooleanProperty1") + .HasColumnType("bool") + .HasColumnName("bool_prop_1"); + + b.Property("BooleanProperty2") + .HasColumnType("bool") + .HasColumnName("bool_prop_2"); + + b.Property("DecimalProperty1") + .HasColumnType("numeric") + .HasColumnName("dec_prop_1"); + + b.Property("DecimalProperty2") + .HasColumnType("numeric") + .HasColumnName("dec_prop_2"); + + b.Property("IntegerProperty1") + .HasColumnType("integer") + .HasColumnName("int_prop_1"); + + b.Property("IntegerProperty2") + .HasColumnType("integer") + .HasColumnName("int_prop_2"); + + b.Property("LongProperty1") + .HasColumnType("bigint") + .HasColumnName("long_prop_1"); + + b.Property("LongProperty2") + .HasColumnType("bigint") + .HasColumnName("long_prop_2"); + + b.Property("StringProperty1") + .HasColumnType("text") + .HasColumnName("str_prop_1"); + + b.Property("StringProperty2") + .HasColumnType("text") + .HasColumnName("str_prop_2"); + + b.Property("StringProperty3") + .HasColumnType("text") + .HasColumnName("str_prop_3"); + + b.Property("TimeZoneId") + .HasColumnType("text") + .HasColumnName("time_zone_id"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simprop_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("RepeatCount") + .HasColumnType("bigint") + .HasColumnName("repeat_count"); + + b.Property("RepeatInterval") + .HasColumnType("bigint") + .HasColumnName("repeat_interval"); + + b.Property("TimesTriggered") + .HasColumnType("bigint") + .HasColumnName("times_triggered"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.ToTable("qrtz_simple_triggers", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Property("SchedulerName") + .HasColumnType("text") + .HasColumnName("sched_name"); + + b.Property("TriggerName") + .HasColumnType("text") + .HasColumnName("trigger_name"); + + b.Property("TriggerGroup") + .HasColumnType("text") + .HasColumnName("trigger_group"); + + b.Property("CalendarName") + .HasColumnType("text") + .HasColumnName("calendar_name"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndTime") + .HasColumnType("bigint") + .HasColumnName("end_time"); + + b.Property("JobData") + .HasColumnType("bytea") + .HasColumnName("job_data"); + + b.Property("JobGroup") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_group"); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("MisfireInstruction") + .HasColumnType("smallint") + .HasColumnName("misfire_instr"); + + b.Property("NextFireTime") + .HasColumnType("bigint") + .HasColumnName("next_fire_time"); + + b.Property("PreviousFireTime") + .HasColumnType("bigint") + .HasColumnName("prev_fire_time"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("StartTime") + .HasColumnType("bigint") + .HasColumnName("start_time"); + + b.Property("TriggerState") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_state"); + + b.Property("TriggerType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trigger_type"); + + b.HasKey("SchedulerName", "TriggerName", "TriggerGroup"); + + b.HasIndex("NextFireTime") + .HasDatabaseName("idx_qrtz_t_next_fire_time"); + + b.HasIndex("TriggerState") + .HasDatabaseName("idx_qrtz_t_state"); + + b.HasIndex("NextFireTime", "TriggerState") + .HasDatabaseName("idx_qrtz_t_nft_st"); + + b.HasIndex("SchedulerName", "JobName", "JobGroup"); + + b.ToTable("qrtz_triggers", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applicants.ApplicantTenantMap", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastUpdated") + .HasColumnType("timestamp without time zone"); + + b.Property("OidcSubUsername") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TenantName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OidcSubUsername"); + + b.HasIndex("OidcSubUsername", "TenantId") + .IsUnique(); + + b.ToTable("ApplicantTenantMaps", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.CasClientCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialMinistry") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("MinistryPrefix") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.HasKey("Id"); + + b.ToTable("CasClientCodes", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Integrations.DynamicUrl", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("KeyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DynamicUrls", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Community", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Communities", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.EconomicRegion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("EconomicRegionName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("EconomicRegions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.ElectoralDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ElectoralDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ElectoralDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.HasKey("Id"); + + b.ToTable("ElectoralDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.RegionalDistrict", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicRegionCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RegionalDistrictCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrictName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("RegionalDistricts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SectorId") + .HasColumnType("uuid"); + + b.Property("SubSectorCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubSectorName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SectorId"); + + b.ToTable("SubSectors", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Tokens.TenantToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TenantTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)") + .HasColumnName("ApplicationName"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("BrowserInfo"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientId"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ClientIpAddress"); + + b.Property("ClientName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ClientName"); + + b.Property("Comments") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Comments"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("CorrelationId"); + + b.Property("Exceptions") + .HasColumnType("text"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HttpMethod") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("HttpMethod"); + + b.Property("HttpStatusCode") + .HasColumnType("integer") + .HasColumnName("HttpStatusCode"); + + b.Property("ImpersonatorTenantId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorTenantId"); + + b.Property("ImpersonatorTenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("ImpersonatorTenantName"); + + b.Property("ImpersonatorUserId") + .HasColumnType("uuid") + .HasColumnName("ImpersonatorUserId"); + + b.Property("ImpersonatorUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ImpersonatorUserName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("TenantName"); + + b.Property("Url") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Url"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("UserId"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ExecutionTime"); + + b.HasIndex("TenantId", "UserId", "ExecutionTime"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ExecutionDuration") + .HasColumnType("integer") + .HasColumnName("ExecutionDuration"); + + b.Property("ExecutionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ExecutionTime"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("MethodName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("MethodName"); + + b.Property("Parameters") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("Parameters"); + + b.Property("ServiceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("ServiceName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "ServiceName", "MethodName", "ExecutionTime"); + + b.ToTable("AuditLogActions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuditLogId") + .HasColumnType("uuid") + .HasColumnName("AuditLogId"); + + b.Property("ChangeTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("ChangeTime"); + + b.Property("ChangeType") + .HasColumnType("smallint") + .HasColumnName("ChangeType"); + + b.Property("EntityId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityId"); + + b.Property("EntityTenantId") + .HasColumnType("uuid"); + + b.Property("EntityTypeFullName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("EntityTypeFullName"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AuditLogId"); + + b.HasIndex("TenantId", "EntityTypeFullName", "EntityId"); + + b.ToTable("EntityChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntityChangeId") + .HasColumnType("uuid"); + + b.Property("NewValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("NewValue"); + + b.Property("OriginalValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("OriginalValue"); + + b.Property("PropertyName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("PropertyName"); + + b.Property("PropertyTypeFullName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("PropertyTypeFullName"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("EntityChangeId"); + + b.ToTable("EntityPropertyChanges", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.BackgroundJobs.BackgroundJobRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsAbandoned") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("JobArgs") + .IsRequired() + .HasMaxLength(1048576) + .HasColumnType("character varying(1048576)"); + + b.Property("JobName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("LastTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("NextTryTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Priority") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)15); + + b.Property("TryCount") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((short)0); + + b.HasKey("Id"); + + b.HasIndex("IsAbandoned", "NextTryTime"); + + b.ToTable("BackgroundJobs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedProviders") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DefaultValue") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsAvailableToHost") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ValueType") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Features", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("FeatureGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.FeatureManagement.FeatureValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("FeatureValues", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityClaimType", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsStatic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Regex") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RegexDescription") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ValueType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClaimTypes", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityLinkUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("SourceTenantId") + .HasColumnType("uuid"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("TargetTenantId") + .HasColumnType("uuid"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceUserId", "SourceTenantId", "TargetUserId", "TargetTenantId") + .IsUnique(); + + b.ToTable("LinkUsers", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasColumnName("IsDefault"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("IsPublic"); + + b.Property("IsStatic") + .HasColumnType("boolean") + .HasColumnName("IsStatic"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySecurityLog", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Action") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("ApplicationName") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("BrowserInfo") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Identity") + .HasMaxLength(96) + .HasColumnType("character varying(96)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TenantName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Action"); + + b.HasIndex("TenantId", "ApplicationName"); + + b.HasIndex("TenantId", "Identity"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("SecurityLogs", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentitySession", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Device") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeviceInfo") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IpAddresses") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("LastAccessed") + .HasColumnType("timestamp without time zone"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SignedIn") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Device"); + + b.HasIndex("SessionId"); + + b.HasIndex("TenantId", "UserId"); + + b.ToTable("Sessions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("Email"); + + b.Property("EmailConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("EmailConfirmed"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("IsActive"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsExternal") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsExternal"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastPasswordChangeTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("LockoutEnabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Name"); + + b.Property("NormalizedEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedEmail"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("NormalizedUserName"); + + b.Property("OidcSub") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("PasswordHash"); + + b.Property("PhoneNumber") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("PhoneNumber"); + + b.Property("PhoneNumberConfirmed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("PhoneNumberConfirmed"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("SecurityStamp"); + + b.Property("ShouldChangePasswordOnNextLogin") + .HasColumnType("boolean"); + + b.Property("Surname") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("Surname"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TwoFactorEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("TwoFactorEnabled"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("UserName"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("NormalizedEmail"); + + b.HasIndex("NormalizedUserName"); + + b.HasIndex("UserName"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ClaimValue") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserDelegation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EndTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SourceUserId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TargetUserId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("UserDelegations", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderDisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(196) + .HasColumnType("character varying(196)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "LoginProvider"); + + b.HasIndex("LoginProvider", "ProviderKey"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "UserId"); + + b.HasIndex("UserId", "OrganizationUnitId"); + + b.ToTable("UserOrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId", "UserId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(95) + .HasColumnType("character varying(95)") + .HasColumnName("Code"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("DisplayName"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("ParentId"); + + b.ToTable("OrganizationUnits", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.Property("OrganizationUnitId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("OrganizationUnitId", "RoleId"); + + b.HasIndex("RoleId", "OrganizationUnitId"); + + b.ToTable("OrganizationUnitRoles", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("MultiTenancySide") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParentName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateCheckers") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("GroupName"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Permissions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGrant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("PermissionGrants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.PermissionManagement.PermissionGroupDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("PermissionGroups", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.Setting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ProviderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("Name", "ProviderName", "ProviderKey") + .IsUnique(); + + b.ToTable("Settings", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.SettingManagement.SettingDefinitionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultValue") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExtraProperties") + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsEncrypted") + .HasColumnType("boolean"); + + b.Property("IsInherited") + .HasColumnType("boolean"); + + b.Property("IsVisibleToClients") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Providers") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SettingDefinitions", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EntityVersion") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Tenants", (string)null); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("TenantId", "Name"); + + b.ToTable("TenantConnectionStrings", (string)null); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzBlobTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("BlobTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzCronTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("CronTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimplePropertyTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimplePropertyTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzSimpleTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", "Trigger") + .WithMany("SimpleTriggers") + .HasForeignKey("SchedulerName", "TriggerName", "TriggerGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.HasOne("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", "JobDetail") + .WithMany("Triggers") + .HasForeignKey("SchedulerName", "JobName", "JobGroup") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobDetail"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.SubSector", b => + { + b.HasOne("Unity.GrantManager.Locality.Sector", "Sector") + .WithMany("SubSectors") + .HasForeignKey("SectorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sector"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLogAction", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("Actions") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.AuditLog", null) + .WithMany("EntityChanges") + .HasForeignKey("AuditLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityPropertyChange", b => + { + b.HasOne("Volo.Abp.AuditLogging.EntityChange", null) + .WithMany("PropertyChanges") + .HasForeignKey("EntityChangeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRoleClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany("Claims") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserClaim", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Claims") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserLogin", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Logins") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserOrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("OrganizationUnits") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserRole", b => + { + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUserToken", b => + { + b.HasOne("Volo.Abp.Identity.IdentityUser", null) + .WithMany("Tokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany() + .HasForeignKey("ParentId"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnitRole", b => + { + b.HasOne("Volo.Abp.Identity.OrganizationUnit", null) + .WithMany("Roles") + .HasForeignKey("OrganizationUnitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Volo.Abp.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.TenantConnectionString", b => + { + b.HasOne("Volo.Abp.TenantManagement.Tenant", null) + .WithMany("ConnectionStrings") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzJobDetail", b => + { + b.Navigation("Triggers"); + }); + + modelBuilder.Entity("AppAny.Quartz.EntityFrameworkCore.Migrations.QuartzTrigger", b => + { + b.Navigation("BlobTriggers"); + + b.Navigation("CronTriggers"); + + b.Navigation("SimplePropertyTriggers"); + + b.Navigation("SimpleTriggers"); + }); + + modelBuilder.Entity("Unity.GrantManager.Locality.Sector", b => + { + b.Navigation("SubSectors"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.AuditLog", b => + { + b.Navigation("Actions"); + + b.Navigation("EntityChanges"); + }); + + modelBuilder.Entity("Volo.Abp.AuditLogging.EntityChange", b => + { + b.Navigation("PropertyChanges"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityRole", b => + { + b.Navigation("Claims"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.IdentityUser", b => + { + b.Navigation("Claims"); + + b.Navigation("Logins"); + + b.Navigation("OrganizationUnits"); + + b.Navigation("Roles"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("Volo.Abp.Identity.OrganizationUnit", b => + { + b.Navigation("Roles"); + }); + + modelBuilder.Entity("Volo.Abp.TenantManagement.Tenant", b => + { + b.Navigation("ConnectionStrings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.cs new file mode 100644 index 0000000000..74899e427c --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/20260307012926_Fixsnaphot.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.HostMigrations +{ + /// + public partial class Fixsnaphot : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs index f4cf613605..d034c14e8e 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/HostMigrations/GrantManagerDbContextModelSnapshot.cs @@ -472,16 +472,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Id") .HasColumnType("uuid"); - b.Property("ClientCode") - .IsRequired() - .HasColumnType("text"); - - b.Property("ClientId") - .HasColumnType("text"); - b.Property("ConcurrencyStamp") + .IsConcurrencyToken() .IsRequired() - .HasColumnType("text"); + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); b.Property("CreationTime") .HasColumnType("timestamp without time zone") @@ -491,15 +487,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("CreatorId"); - b.Property("Description") + b.Property("ExtraProperties") .IsRequired() - .HasColumnType("text"); - - b.Property("FinancialMinistry") - .HasColumnType("text"); - - b.Property("IsActive") - .HasColumnType("boolean"); + .HasColumnType("text") + .HasColumnName("ExtraProperties"); b.Property("LastUpdated") .HasColumnType("timestamp without time zone"); @@ -525,6 +516,84 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ApplicantTenantMaps", (string)null); }); + modelBuilder.Entity("Unity.GrantManager.Integrations.CasClientCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.Property("ClientId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialMinistry") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("MinistryPrefix") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)"); + + b.HasKey("Id"); + + b.ToTable("CasClientCodes", (string)null); + }); + modelBuilder.Entity("Unity.GrantManager.Integrations.DynamicUrl", b => { b.Property("Id") From 73e791f56c954b5619cff8a9b1911d0dfaf33080 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 6 Mar 2026 18:31:04 -0800 Subject: [PATCH 014/245] AB#32009 Align v1 prompt contracts and preserve v0 path --- .../AI/Prompts/AnalysisPrompts.cs | 20 +++++++------- .../AI/Prompts/AttachmentPrompts.cs | 26 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) 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 index 97841e760a..6cb16655c3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AnalysisPrompts.cs @@ -2,13 +2,13 @@ namespace Unity.GrantManager.AI { internal static class AnalysisPrompts { - public const string DefaultRubricV0 = @"ELIGIBILITY REQUIREMENTS: Project aligns with program objectives; Applicant is an eligible entity; Budget is reasonable and justified; Timeline is realistic. + public const string DefaultRubric = @"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."; - public const string DefaultRubric = @"BC GOVERNMENT GRANT EVALUATION RUBRIC: + public const string DefaultRubricV0 = @"BC GOVERNMENT GRANT EVALUATION RUBRIC: 1. ELIGIBILITY REQUIREMENTS: - Project must align with program objectives @@ -49,7 +49,7 @@ internal static class AnalysisPrompts 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 OutputTemplateV0 = @"{ + public const string OutputTemplate = @"{ ""rating"": """", ""errors"": [ { @@ -71,7 +71,7 @@ internal static class AnalysisPrompts ] }"; - public const string RulesV0 = PromptCoreRules.UseProvidedEvidence + "\n" + public const string Rules = PromptCoreRules.UseProvidedEvidence + "\n" + "- Do not invent fields, documents, requirements, or facts.\n" + @"- Treat missing or empty values as findings only when they weaken rubric evidence. - Prefer material issues; avoid nitpicking. @@ -92,7 +92,7 @@ internal static class AnalysisPrompts WARNING: Issue that could negatively affect the application's approval. RECOMMENDATION: Reviewer-facing improvement or follow-up consideration."; - public const string OutputTemplate = @"{ + public const string OutputTemplateV0 = @"{ ""rating"": ""HIGH/MEDIUM/LOW"", ""warnings"": [ { @@ -115,7 +115,7 @@ internal static class AnalysisPrompts ""dismissed"": [] }"; - public const string Rules = @"- Use only SCHEMA, DATA, ATTACHMENTS, and RUBRIC as evidence. + public const string RulesV0 = @"- 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. @@ -135,11 +135,11 @@ internal static class AnalysisPrompts 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."); + "Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES, return review findings."); public static readonly string SystemPromptV0 = PromptHeader.Build( "You are an expert grant analyst assistant for human reviewers.", - "Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES, return review findings."); + "Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SEVERITY, SCORE, OUTPUT, and RULES, return review findings."); public static string GetRubric(bool useV0) => useV0 ? DefaultRubricV0 : DefaultRubric; public static string GetSystemPrompt(bool useV0) => useV0 ? SystemPromptV0 : SystemPrompt; @@ -162,10 +162,10 @@ public static string BuildUserPrompt( { var output = useV0 ? OutputTemplateV0 : OutputTemplate; var rules = useV0 ? RulesV0 : Rules; - var severitySection = useV0 ? string.Empty : $@"SEVERITY + var severitySection = useV0 ? $@"SEVERITY {SeverityRules} -"; +" : string.Empty; return $@"SCHEMA {schemaJson} 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 index dd950b2065..6e83ea6a13 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/AttachmentPrompts.cs @@ -11,23 +11,11 @@ internal static class AttachmentPrompts "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 OutputSectionV0 = @"OUTPUT { ""summary"": """" }"; - 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 const string RulesSectionV0 = "- Use only ATTACHMENT as evidence.\n" + public const string RulesSection = "- Use only ATTACHMENT as evidence.\n" + "- If ATTACHMENT.text is present, summarize actual content.\n" + "- If ATTACHMENT.text is null or empty, provide a conservative file-level summary.\n" + PromptCoreRules.NoInvention + "\n" @@ -41,6 +29,18 @@ internal static class AttachmentPrompts + PromptCoreRules.ValidJsonOnly + "\n" + PromptCoreRules.PlainJsonOnly; + public const string OutputSectionV0 = @"OUTPUT +- Plain text only +- 1-2 complete sentences"; + + public const string RulesSectionV0 = @"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 GetSystemPrompt(bool useV0) => useV0 ? SystemPromptV0 : SystemPrompt; public static string GetOutputSection(bool useV0) => useV0 ? OutputSectionV0 : OutputSection; public static string GetRulesSection(bool useV0) => useV0 ? RulesSectionV0 : RulesSection; From c0e25bdf5ffd3e603fe4846268edca877d59e955 Mon Sep 17 00:00:00 2001 From: Jacob Smith Date: Fri, 6 Mar 2026 18:49:26 -0800 Subject: [PATCH 015/245] AB#32009 Fix async action authorization loop and centralize extractor dispatch --- .../AI/TextExtractionService.cs | 57 +++++++++++-------- .../GrantApplicationAppService.cs | 10 ++-- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs index 9aad041a5e..e2353ca796 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs @@ -23,10 +23,22 @@ public partial class TextExtractionService : ITextExtractionService, ITransientD private const int MaxDocxTableRows = 2000; private const int MaxDocxTableCellsPerRow = 50; 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"] = (name, content) => ExtractTextFromWordDocx(name, content), + [".xls"] = ExtractTextFromExcelFile, + [".xlsx"] = ExtractTextFromExcelFile + }; } public Task ExtractTextAsync(string fileName, byte[] fileContent, string contentType) @@ -42,46 +54,41 @@ public Task ExtractTextAsync(string fileName, byte[] fileContent, string var normalizedContentType = contentType?.ToLowerInvariant() ?? string.Empty; var extension = Path.GetExtension(fileName)?.ToLowerInvariant() ?? string.Empty; - string rawText; + 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/") || - extension == ".txt" || - extension == ".csv" || - extension == ".json" || - extension == ".xml") + if (normalizedContentType.Contains("text/")) { - rawText = ExtractTextFromTextFile(fileContent); + var rawText = ExtractTextFromTextFile(fileContent); return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } - if (normalizedContentType.Contains("pdf") || extension == ".pdf") + if (normalizedContentType.Contains("pdf")) { - rawText = ExtractTextFromPdfFile(fileName, fileContent); + var rawText = ExtractTextFromPdfFile(fileName, fileContent); return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } if (normalizedContentType.Contains("word") || normalizedContentType.Contains("msword") || - normalizedContentType.Contains("officedocument.wordprocessingml") || - extension == ".doc" || - extension == ".docx") + normalizedContentType.Contains("officedocument.wordprocessingml")) { - if (extension == ".docx" || normalizedContentType.Contains("officedocument.wordprocessingml")) - { - rawText = ExtractTextFromWordDocx(fileName, fileContent); - return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); - } - - _logger.LogDebug("Legacy .doc extraction is not supported for {FileName}", fileName); - return Task.FromResult(string.Empty); + var rawText = ExtractTextFromWordDocx(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } - if (normalizedContentType.Contains("excel") || - normalizedContentType.Contains("spreadsheet") || - extension == ".xls" || - extension == ".xlsx") + if (normalizedContentType.Contains("excel") || normalizedContentType.Contains("spreadsheet")) { - rawText = ExtractTextFromExcelFile(fileName, fileContent); + var rawText = ExtractTextFromExcelFile(fileName, fileContent); return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs index 075d568663..f6c4943e86 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -974,11 +974,11 @@ public async Task> GetActions(Guid applicati // NOTE: Authorization is applied on the AppService layer and is false by default // AUTHORIZATION HANDLING - actionDtos.ForEach(async item => - { - item.IsPermitted = item.IsPermitted && (await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(item.ApplicationAction))); - item.IsAuthorized = true; - }); + foreach (var item in actionDtos) + { + item.IsPermitted = item.IsPermitted && (await AuthorizationService.IsGrantedAsync(application, GetActionAuthorizationRequirement(item.ApplicationAction))); + item.IsAuthorized = true; + } return new ListResultDto(actionDtos); } From 377ceac4a057c762a02b3ee6bda57170a6d9a25f Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:39:06 -0700 Subject: [PATCH 016/245] feature/AB#31884 - Remove "Title, Role" placeholder text on assessments --- .../Views/Shared/Components/UserInfoWidget/Default.cshtml | 1 - 1 file changed, 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/UserInfoWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/UserInfoWidget/Default.cshtml index 4e7e5b93fd..1618cdc453 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/UserInfoWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/UserInfoWidget/Default.cshtml @@ -12,7 +12,6 @@
@Model.Badge
@Model.DisplayName
-
@Model.Title
From 27a73a676ae5d07d68c373c1b8aadae70e018060 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 9 Mar 2026 11:51:27 -0700 Subject: [PATCH 017/245] feature/AB#32221 - Rename default sort column "Created Date" in Applicants table --- .../src/Unity.GrantManager.Web/Pages/Applicants/Index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js index 0ff7065093..88fed040f6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Index.js @@ -376,7 +376,7 @@ $(function () { function getCreationTimeColumn(columnIndex) { return { - title: 'Creation Date', + title: 'Created Date', data: 'creationTime', name: 'creationTime', className: 'data-table-header', From 05f3a4a61903c5b60d7a202f9af2fec4cfdda4d7 Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:30:41 -0700 Subject: [PATCH 018/245] feature/AB#31961 - Render Applicant Name as Hyperlink --- .../Pages/GrantApplications/Index.js | 14 +++++++++++++- .../ApplicationBreadcrumbWidget.cs | 1 + .../ApplicationBreadcrumbWidgetViewModel.cs | 1 + .../ApplicationBreadcrumbWidget/Default.cshtml | 14 +++++++++++++- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js index 9de9b4752a..dc040ad947 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Index.js @@ -581,7 +581,19 @@ $(function () { data: 'applicant.applicantName', name: 'applicantName', className: 'data-table-header', - index: columnIndex + index: columnIndex, + render: function(data, type, row) { + let applicantName = (data === "") ? '(Unknown Applicant)' : data; + + const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const isGuid = row.applicant.id && guidPattern.test(row.applicant.id); + + if (isGuid) { + return `${applicantName}`; + } else { + return applicantName; + } + }, } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidget.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidget.cs index f229ac79d7..42ae522e40 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidget.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidget.cs @@ -26,6 +26,7 @@ public async Task InvokeAsync(Guid applicationId) return View(new ApplicationBreadcrumbWidgetViewModel() { + ApplicantId = applicationApplicant.ApplicantId, ApplicantName = applicationApplicant.ApplicantName, ApplicationStatus = applicationApplicant.ApplicationStatus, ReferenceNo = applicationApplicant.ApplicationReferenceNo, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidgetViewModel.cs index 4ad298762c..5a664ddc66 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/ApplicationBreadcrumbWidgetViewModel.cs @@ -5,6 +5,7 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.ApplicationBreadcrumbWi public class ApplicationBreadcrumbWidgetViewModel { public string ReferenceNo { get; set; } = string.Empty; + public Guid ApplicantId { get; set; } = Guid.Empty; public string ApplicantName { get; set; } = string.Empty; public string ApplicationStatus { get; set; } = string.Empty; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml index bd60256de7..2eb68d57ef 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml @@ -6,7 +6,19 @@
@Model.ReferenceNo
-
@Model.ApplicantName
+
+ @if (Model.ApplicantId != Guid.Empty) + { + + @(Model.ApplicantName ?? "(Unknown Applicant)") + + } + else + { + @Model.ApplicantName + } +
@Model.SubmissionFormDescription
@Model.ApplicationStatus
From b37f6ed624c273eb30ee397d6d9675db60c0f55a Mon Sep 17 00:00:00 2001 From: Patrick <135162612+plavoie-BC@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:39:08 -0700 Subject: [PATCH 019/245] feature/AB#31961 - Render Unknown Applicant Name as Hyperlink --- .../Components/ApplicationBreadcrumbWidget/Default.cshtml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml index 2eb68d57ef..86babd9e71 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationBreadcrumbWidget/Default.cshtml @@ -2,6 +2,10 @@ @model ApplicationBreadcrumbWidgetViewModel +@{ + var renderedApplicantName = string.IsNullOrWhiteSpace(Model.ApplicantName) ? "Unknown Applicant" : Model.ApplicantName; +} +