diff --git a/applications/Unity.AutoUI/cypress.config.ts b/applications/Unity.AutoUI/cypress.config.ts index c227e4bc3d..2d846e5c58 100644 --- a/applications/Unity.AutoUI/cypress.config.ts +++ b/applications/Unity.AutoUI/cypress.config.ts @@ -1,22 +1,72 @@ -import { defineConfig } from 'cypress'; +import { defineConfig } from "cypress"; +import FormData from "form-data"; +import fs from "fs"; +import path from "path"; + // https://docs.cypress.io/guides/references/configuration export default defineConfig({ e2e: { - setupNodeEvents(on, config) { - // implement node event listeners here + setupNodeEvents(on) { + on("task", { + async uploadChefsFile({ + baseURL, + authToken, + filePath, + }: { + baseURL: string; + authToken: string; + filePath: string; + }) { + const fileBuffer = fs.readFileSync(filePath); + const fileName = path.basename(filePath); + + const form = new FormData(); + form.append("files", fileBuffer, { + filename: fileName, + contentType: "text/plain", + }); + + // Use getBuffer() so fetch receives a complete binary buffer + // rather than a piped stream (which causes "Unexpected end of form") + const formBuffer = form.getBuffer(); + const formHeaders = form.getHeaders(); + + const response = await fetch(`${baseURL}/app/api/v1/files`, { + method: "POST", + headers: { + Authorization: `Bearer ${authToken}`, + ...formHeaders, + }, + body: formBuffer as unknown as BodyInit, + }); + + if (!response.ok) { + throw new Error( + `File upload failed: ${response.status} ${await response.text()}`, + ); + } + + return response.json(); + }, + }); }, - baseUrl: 'https://developer.gov.bc.ca/', + specPattern: [ + "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", + "cypress/scripts/**/*.cy.{js,jsx,ts,tsx}", + "cypress/regression/**/*.cy.{js,jsx,ts,tsx}", + ], + baseUrl: "https://dev-unity.apps.silver.devops.gov.bc.ca/", defaultCommandTimeout: 20000, // Time, in milliseconds, to wait until most DOM based commands are considered timed out. - viewportWidth: 1440, // Default width in pixels. - viewportHeight: 900, // Default height in pixels. + viewportWidth: 1440, // Default width in pixels. + viewportHeight: 900, // Default height in pixels. chromeWebSecurity: false, // Chromium-based browser's Web Security for same-origin policy and insecure mixed content. - testIsolation: false, // Set true to ensure a clean browser context between test cases. - retries: // The number of times to retry a failing test. - { - "runMode": 3, - "openMode": 0 - }, + testIsolation: false, // Set true to ensure a clean browser context between test cases. + // The number of times to retry a failing test. + retries: { + runMode: 3, + openMode: 0, + }, experimentalMemoryManagement: true, - numTestsKeptInMemory: 3 - } -}); \ No newline at end of file + numTestsKeptInMemory: 3, + }, +}); diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 0c08e11ce6..6ae85878e2 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -306,20 +306,23 @@ describe("Send an email", () => { }); it("Select Email Template", () => { - cy.intercept("GET", "/api/app/template/*/template-by-id").as( - "loadTemplate", - ); - cy.get("#template", { timeout: STANDARD_TIMEOUT }) .should("exist") .should("be.visible") .select(TEMPLATE_NAME); - cy.wait("@loadTemplate", { timeout: STANDARD_TIMEOUT }); - cy.get("#template") .find("option:selected") .should("have.text", TEMPLATE_NAME); + + // #EmailBody is a hidden textarea backing the rich-text editor. + // Template selection populates the visible RTE but does not auto-sync + // the backing field — trigger the change manually if still empty. + cy.get("#EmailBody", { timeout: STANDARD_TIMEOUT }).then(($el) => { + if (($el.val() as string).trim() === "") { + cy.wrap($el).invoke("val", "Test email body").trigger("change"); + } + }); }); it("Set Email To address", () => { diff --git a/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt b/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt new file mode 100644 index 0000000000..883ad3eacc --- /dev/null +++ b/applications/Unity.AutoUI/cypress/fixtures/test-attachment.txt @@ -0,0 +1,3 @@ +Maple Ridge Community Resource Development Initiative +Test Attachment - Automated Regression Submission +Generated by Cypress automation script. diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts index 6194b02f1f..22f0f2eee6 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts @@ -34,6 +34,13 @@ export class ApplicationDetailsPage extends BasePage { onHold: "#Application_OnHoldButton", }; + // Confirm action modal selectors (SweetAlert2) + private readonly confirmModal = { + modal: ".swal2-popup", + confirmButton: "button.swal2-confirm", + cancelButton: "button.swal2-cancel", + }; + // Field selectors for Summary/Info Panel private readonly summaryFields = { category: "Category", @@ -106,55 +113,63 @@ export class ApplicationDetailsPage extends BasePage { /** * Navigate to Submission tab */ - goToSubmissionTab(): void { + goToSubmissionTab(): this { this.clickElement(this.tabs.submission); + return this; } /** * Navigate to Review & Assessment tab */ - goToReviewAssessmentTab(): void { + goToReviewAssessmentTab(): this { + this.dismissErrorModalIfPresent(); this.clickElement(this.tabs.reviewAssessment); + return this; } /** * Navigate to Project Info tab */ - goToProjectInfoTab(): void { + goToProjectInfoTab(): this { this.clickElement(this.tabs.projectInfo); + return this; } /** * Navigate to Applicant Info tab */ - goToApplicantInfoTab(): void { + goToApplicantInfoTab(): this { this.clickElement(this.tabs.applicantInfo); + return this; } /** * Navigate to Funding Agreement tab */ - goToFundingAgreementTab(): void { + goToFundingAgreementTab(): this { this.clickElement(this.tabs.fundingAgreement); + return this; } /** * Navigate to Payment Info tab */ - goToPaymentInfoTab(): void { + goToPaymentInfoTab(): this { this.clickElement(this.tabs.paymentInfo); + return this; } /** * Verify all tabs are visible */ - verifyAllTabsVisible(): void { + verifyAllTabsVisible(): this { cy.get(this.tabs.submission).should("be.visible"); cy.get(this.tabs.reviewAssessment).should("be.visible"); cy.get(this.tabs.projectInfo).should("be.visible"); cy.get(this.tabs.applicantInfo).should("be.visible"); cy.get(this.tabs.fundingAgreement).should("be.visible"); cy.get(this.tabs.paymentInfo).should("be.visible"); + return this; } /** @@ -168,7 +183,7 @@ export class ApplicationDetailsPage extends BasePage { | "applicantInfo" | "fundingAgreement" | "paymentInfo" - ): void { + ): this { const tabSelectors: Record = { submission: this.tabs.submission, reviewAssessment: this.tabs.reviewAssessment, @@ -178,6 +193,7 @@ export class ApplicationDetailsPage extends BasePage { paymentInfo: this.tabs.paymentInfo, }; cy.get(tabSelectors[tabName]).should("have.class", "active"); + return this; } /** @@ -294,14 +310,132 @@ export class ApplicationDetailsPage extends BasePage { this.verifyInputValue("#TotalBudgetInputAR", budget); } + // ============ Payment Info Methods ============ + + /** + * Enter Supplier Number + */ + enterSupplierNumber(supplierNumber: string): this { + cy.get("#SupplierNumber", { timeout: 20000 }) + .clear({ force: true }) + .type(supplierNumber, { force: true }) + .trigger("change") + .blur(); + return this; + } + + /** + * Click elsewhere to trigger save button enable + */ + clickElsewhere(): this { + cy.get("body").click(0, 0); + return this; + } + + /** + * Click Payment Info Save button + */ + clickPaymentInfoSave(): this { + cy.get("#nav-payment-info", { timeout: 20000 }) + .contains("button", "Save") + .click({ force: true }); + // Wait briefly for save to process + cy.wait(1000); + return this; + } + + /** + * Verify Site Info table is populated + */ + verifySiteInfoTablePopulated(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) + .should("have.length.at.least", 1); + return this; + } + + /** + * Verify Site Info table has data in specific columns + */ + verifySiteInfoTableHasData(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }).first().within(() => { + cy.get("td").eq(0).should("not.be.empty"); // Site # + cy.get("td").eq(1).should("not.be.empty"); // Pay Group + cy.get("td").eq(2).should("not.be.empty"); // Mailing Address + }); + return this; + } + + /** + * Click Edit button in Site Info table + */ + clickSiteInfoEdit(): this { + cy.get("#SiteInfoTable tbody tr", { timeout: 20000 }) + .first() + .find("button, a") + .filter(':contains("Edit"), [title="Edit"], .edit-btn, .btn-edit') + .first() + .click({ force: true }); + return this; + } + + /** + * Wait for Edit Site modal to appear + */ + waitForEditSiteModal(): this { + cy.get(".modal-content", { timeout: 20000 }) + .contains(".modal-title", "Edit Site") + .should("be.visible"); + return this; + } + + /** + * Select Payment Group in Edit Site modal + */ + selectPaymentGroup(paymentGroup: "EFT" | "Cheque"): this { + cy.get("#Site_PaymentGroup", { timeout: 20000 }) + .select(paymentGroup, { force: true }); + return this; + } + + /** + * Click Save Changes in Edit Site modal + */ + clickSaveChanges(): this { + cy.get(".modal-footer", { timeout: 20000 }) + .contains("button", "SAVE CHANGES") + .click({ force: true }); + cy.wait(2000); // Wait for save to process + cy.get("body").type("{esc}"); + cy.get(".modal.show, .modal.fade.show", { timeout: 20000 }).should("not.exist"); + cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + return this; + } + + /** + * Click Cancel in Edit Site modal + */ + clickModalCancel(): this { + cy.get(".modal-footer", { timeout: 20000 }) + .contains("button", "CANCEL") + .click({ force: true }); + return this; + } + // ============ Status Actions Dropdown Methods ============ /** * Open the Status Actions dropdown */ openStatusActionsDropdown(): void { - this.clickElement(this.statusActions.dropdownToggle); - cy.get(this.statusActions.dropdownMenu).should("be.visible"); + cy.get(this.statusActions.dropdownMenu).then(($menu) => { + if (!$menu.is(":visible")) { + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }) + .should("exist") + .scrollIntoView() + .click({ force: true }); + } + }); + cy.get(this.statusActions.dropdownMenu, { timeout: 10000 }).should("be.visible"); } /** @@ -337,11 +471,33 @@ 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(): void { + clickApprove(): this { + this.openStatusActionsDropdown(); + cy.get(this.statusActions.completeAssessment).then(($btn) => { + if (!$btn.is(":disabled")) { + cy.wrap($btn).click({ force: true }); + cy.get("body").then(($body) => { + if ($body.find(this.confirmModal.modal).filter(":visible").length > 0) { + cy.get(this.confirmModal.modal) + .find(this.confirmModal.confirmButton) + .click({ force: true }); + } + }); + // Wait for page to stabilize after status transition + cy.get(this.statusActions.dropdownToggle, { timeout: 20000 }).should("be.visible"); + cy.wait(2000); + } + }); + // Always reopen dropdown fresh before clicking Approve (dropdown may have closed) this.openStatusActionsDropdown(); - this.clickElement(this.statusActions.approve); + cy.get(this.statusActions.approve, { timeout: 10000 }) + .should("exist") + .click({ force: true }); + return this; } /** @@ -384,6 +540,56 @@ export class ApplicationDetailsPage extends BasePage { this.clickElement(this.statusActions.onHold); } + // ============ Confirm Modal Methods ============ + + /** + * Wait for confirm action modal to appear (SweetAlert2) + */ + waitForConfirmModal(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }).should("be.visible"); + return this; + } + + /** + * Click Confirm button in the modal (SweetAlert2) + */ + clickConfirm(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }) + .find(this.confirmModal.confirmButton) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Click Cancel button in the modal (SweetAlert2) + */ + clickCancel(): this { + cy.get(this.confirmModal.modal, { timeout: 20000 }) + .find(this.confirmModal.cancelButton) + .should("be.visible") + .click({ force: true }); + return this; + } + + /** + * Dismiss any error modal if present (SweetAlert2) + * Uses failOnStatusCode: false to not fail if no modal exists + */ + dismissErrorModalIfPresent(): this { + cy.get("body").then(($body) => { + // Only dismiss if it is specifically an error modal (swal2-error icon) + if ($body.find(".swal2-container .swal2-icon.swal2-error").length > 0) { + cy.get(".swal2-container") + .find(".swal2-confirm") + .first() + .click({ force: true }); + cy.wait(500); + } + }); + return this; + } + /** * Verify status action is enabled */ 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..d34c424860 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -0,0 +1,225 @@ +/// + +/** + * 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 (confirmed via dialog) + * + * The submission ID is fetched dynamically from the API after login, + * ensuring tests always run against valid, available data. + */ + +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"; + +const isProd = (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === "prod"; + +// ============ Test Configuration ============ +// Set submissionId to null for dynamic fetch, or provide a value to override +const TEST_CONFIG = { + // 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: 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: 0, + }, +}; + +(isProd ? describe.skip : describe)("Approval Flow Regression Test", () => { + // Page object instances (reused across all tests) + const listPage = new ApplicationsListPage(); + const detailsPage = new ApplicationDetailsPage(); + const reviewPage = new ReviewAssessmentPage(); + const rightTabPage = new ApplicationDetailsRightTabPage(); + + // 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", () => { + listPage.switchToGrantProgram(TEST_CONFIG.grantProgram); + }); + + it("Search for submission", () => { + // Ensure submissionId is available before searching + expect(submissionId, "Submission ID should be set").to.exist; + listPage + .selectQuickDateRange("alltime") + .waitForTableRefresh() + .searchForSubmission(submissionId); + }); + + it("Select submission and open details", () => { + listPage.selectRowByText(submissionId).clickOpenButton(); + }); + + // ============ Review & Assessment ============ + + it("Navigate to Review and Assessment tab", () => { + detailsPage.goToReviewAssessmentTab().verifyActiveTab("reviewAssessment"); + }); + + it("Enter approval details and save", () => { + reviewPage + .verifyFormioLoaded() + .enterApprovedAmount(TEST_CONFIG.approvedAmount) + .setDecisionDateToToday() + .clickSave(); + }); + + it("Create and complete assessment", () => { + // 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) + .clickElsewhere() + .clickPaymentInfoSave(); + }); + + it("Validate and edit site info", () => { + detailsPage + .verifySiteInfoTablePopulated() + .verifySiteInfoTableHasData() + .clickSiteInfoEdit() + .waitForEditSiteModal() + .selectPaymentGroup(TEST_CONFIG.paymentGroup) + .clickSaveChanges(); + }); + + // ============ Comments & Attachments ============ + + it("Add a comment", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); + rightTabPage + .goToCommentsTab() + .addComment(TEST_CONFIG.testComment) + .clickSaveComment(); + }); + + it("Add an attachment", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); + 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 (confirm)", () => { + // Dismiss any error modals from previous steps + detailsPage.dismissErrorModalIfPresent(); + detailsPage.clickApprove().waitForConfirmModal().clickConfirm(); + }); + + // ============ Cleanup ============ + + it("Logout", () => { + cy.logout(); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/scripts/README.md b/applications/Unity.AutoUI/cypress/scripts/README.md new file mode 100644 index 0000000000..528f451a64 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/README.md @@ -0,0 +1,164 @@ +# CHEFS API Testing + +This directory contains Cypress tests for the CHEFS (Common Hosted Form Service) API. + +## Files + +- **chefs-api-submission.cy.ts**: Cypress test spec for CHEFS form submissions +- **chefs-api-config.json**: Environment configuration (baseURL, formId, versionId, headers) +- **chefs-submission-payload.json**: Form submission payload template + +## Setup + +### 1. Get a Valid CHEFS Authentication Token + +JWT tokens expire regularly (typically within hours/days). To get a fresh token: + +#### Option A: From Browser DevTools + +1. Navigate to CHEFS test environment: https://chefs-test.apps.silver.devops.gov.bc.ca +2. Login with your IDIR credentials +3. Open browser DevTools (F12) +4. Go to the **Network** tab +5. Submit a form or perform any API action +6. Find an API request to `/app/api/v1/forms/` +7. Click on the request and go to **Headers** +8. Copy the `Authorization` header value (starts with `Bearer eyJ...`) + +#### Option B: From Curl Command + +If you have a working curl command: + +```bash +curl 'https://chefs-test.apps.silver.devops.gov.bc.ca/...' \ + -H 'authorization: Bearer eyJhbGc...' +``` + +Copy the token from the authorization header. + +### 2. Update cypress.env.json + +Add or update the token in `cypress.env.json`: + +```json +{ + "CHEFS_AUTH_TOKEN": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**Note**: The `Bearer` prefix is optional - the test will handle both formats. + +### 3. Update Configuration (if needed) + +Edit `chefs-api-config.json` to match your environment: + +```json +{ + "environments": { + "test": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "your-form-id-here", + "versionId": "your-version-id-here" + } + } +} +``` + +## Running the Tests + +```bash +# Run all CHEFS API tests +npx cypress run --spec "cypress/scripts/chefs-api-submission.cy.ts" + +# Run in headed mode (see browser) +npx cypress open --spec "cypress/scripts/chefs-api-submission.cy.ts" +``` + +## Test Cases + +1. **Submit form via CHEFS API**: Submit a complete form with all fields +2. **Submit with custom data**: Override specific fields (applicant name, project title, etc.) +3. **Draft submission**: Submit as draft (not final) +4. **Retrieve submission**: Get submission details by ID +5. **Update submission files**: Add/update file attachments + +## Customizing the Payload + +Edit `chefs-submission-payload.json` to customize the form data: + +```json +{ + "submission": { + "data": { + "_ApplicantName": "Your Custom Name", + "_organizationName": "Your Organization", + "_projectTitle": "Your Project", + "_fundingRequest": 50000 + // ... other fields + } + } +} +``` + +## Troubleshooting + +### 401 Unauthorized Error + +**Cause**: Token is expired or invalid + +**Solution**: + +1. Get a fresh token (see Setup step 1) +2. Update `cypress.env.json` with the new token +3. Re-run the tests + +### 400 Bad Request Error + +**Cause**: Payload data doesn't match form schema + +**Solution**: + +1. Check form version ID in `chefs-api-config.json` +2. Verify payload structure matches the CHEFS form schema +3. Use browser DevTools to capture a valid submission payload + +### Test Skipped (No Token) + +**Cause**: `CHEFS_AUTH_TOKEN` not set in `cypress.env.json` + +**Solution**: Add token to `cypress.env.json` (see Setup step 2) + +## Security Note + +⚠️ **Never commit tokens to version control** + +- Add `cypress.env.json` to `.gitignore` +- Use environment variables in CI/CD pipelines +- Rotate tokens regularly +- Store tokens securely (e.g., password manager, CI secrets) + +## CI/CD Integration + +For automated testing in pipelines: + +```bash +# Set token as environment variable +export CYPRESS_CHEFS_AUTH_TOKEN="Bearer eyJhbGc..." + +# Run tests +npx cypress run --spec "cypress/scripts/chefs-api-submission.cy.ts" +``` + +Or use Cypress environment variable syntax in your CI config: + +```yaml +# Example GitHub Actions +env: + CYPRESS_CHEFS_AUTH_TOKEN: ${{ secrets.CHEFS_AUTH_TOKEN }} +``` + +```yaml +# Example GitLab CI +variables: + CYPRESS_CHEFS_AUTH_TOKEN: $CHEFS_AUTH_TOKEN # From CI/CD variables +``` diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json new file mode 100644 index 0000000000..0bf9000101 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-config.json @@ -0,0 +1,23 @@ +{ + "environments": { + "test": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "46e25863-0ead-4aa8-897f-51e45f79e137", + "versionId": "4ef52ead-2cc3-4bdb-a7b7-73be983a7838" + }, + "dev": { + "baseURL": "https://chefs-dev.apps.silver.devops.gov.bc.ca", + "formId": "233f47f9-b566-46c3-926a-73d565bf710f", + "versionId": "1e209d6b-46f5-4ddb-bc79-6e04033231cb" + }, + "uat": { + "baseURL": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "formId": "f2f45aa7-62c5-49ca-8846-b214e02adb46", + "versionId": "1d4d73ec-00e7-4b57-98c9-49d1e0c7d15b" + } + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts new file mode 100644 index 0000000000..ff81ec0ffd --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -0,0 +1,514 @@ +/// + +export {}; + +/** + * CHEFS Form Submission API Test + * + * This test submits a form to CHEFS (Common Hosted Form Service) via API + * All payloads and configuration are customizable via JSON files: + * - cypress/scripts/chefs-submission-payload.json - Form submission data + * - cypress/scripts/chefs-api-config.json - API configuration and headers + */ + +interface ChefsEnvironment { + baseURL: string; + formId: string; + versionId: string; +} + +interface ChefsApiConfig { + environments: Record; + headers: Record; +} + +interface ChefsSubmissionPayload { + draft?: boolean; + submission: { + state: string; + metadata: { + origin: string; + referrer: string; + }; + data: Record; + }; +} + +const TOKEN_PROPERTY_KEYS = [ + "access_token", + "accessToken", + "token", + "id_token", + "idToken", +]; + +function isJwtLike(value: string): boolean { + return /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/.test(value); +} + +function extractTokenFromString(value: string): string { + const trimmed = value.trim(); + + if (trimmed.toLowerCase().startsWith("bearer ")) { + const bearerToken = trimmed.replace(/^Bearer\s+/i, "").trim(); + if (isJwtLike(bearerToken)) { + return bearerToken; + } + } + + if (isJwtLike(trimmed)) { + return trimmed; + } + + try { + return extractTokenFromValue(JSON.parse(trimmed)); + } catch { + return ""; + } +} + +function extractTokenFromArray(values: unknown[]): string { + for (const value of values) { + const token = extractTokenFromValue(value); + if (token) { + return token; + } + } + + return ""; +} + +function extractTokenFromObject(value: Record): string { + for (const key of TOKEN_PROPERTY_KEYS) { + const token = extractTokenFromValue(value[key]); + if (token) { + return token; + } + } + + return extractTokenFromArray(Object.values(value)); +} + +function extractTokenFromValue(value: unknown): string { + if (typeof value === "string") { + return extractTokenFromString(value); + } + + if (Array.isArray(value)) { + return extractTokenFromArray(value); + } + + if (value && typeof value === "object") { + return extractTokenFromObject(value as Record); + } + + return ""; +} + +function extractTokenFromStorage(win: Window): string { + const storages = [win.localStorage, win.sessionStorage]; + + for (const storage of storages) { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key) { + continue; + } + + const value = storage.getItem(key); + if (!value) { + continue; + } + + const token = extractTokenFromValue(value); + if (token) { + return token; + } + } + } + + return ""; +} + +function getChefsHostname(baseURL: string): string { + return new URL(baseURL).hostname; +} + +function waitForIdentityRedirectOrAuthenticatedChefsPage( + baseURL: string, + timeout: number, +): void { + const chefsHostname = getChefsHostname(baseURL); + + cy.location("hostname", { timeout }).should((hostname) => { + const onChefs = hostname === chefsHostname; + const onBcGovIdentity = hostname.endsWith("gov.bc.ca"); + + expect( + onChefs || onBcGovIdentity, + `Expected CHEFS or BC Gov identity host, got '${hostname}'`, + ).to.eq(true); + }); +} + +function completeChefsLogin(environment: ChefsEnvironment, timeout: number): void { + const chefsHostname = getChefsHostname(environment.baseURL); + + cy.visit(`${environment.baseURL}/app`); + + cy.get("#app > div > main > header > header > div > div.d-print-none", { + timeout, + }) + .should("exist") + .click(); + + cy.get( + "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", + { timeout }, + ) + .should("exist") + .click(); + + waitForIdentityRedirectOrAuthenticatedChefsPage(environment.baseURL, timeout); + + cy.location("hostname", { timeout }).then((hostname) => { + if (hostname === chefsHostname) { + cy.log("Already logged in to CHEFS"); + return; + } + + cy.get("#user", { timeout }) + .should("be.visible") + .clear() + .type(Cypress.env("test1username"), { log: false }); + + cy.get("#password", { timeout }) + .should("be.visible") + .clear() + .type(Cypress.env("test1password"), { log: false }); + + cy.contains("Continue", { timeout }).should("be.visible").click(); + + cy.location("hostname", { timeout }).should("eq", chefsHostname); + }); +} + +function visitChefsForm(environment: ChefsEnvironment, timeout: number): void { + cy.visit(`${environment.baseURL}/app/form/submit?f=${environment.formId}`); + cy.location("hostname", { timeout }).should( + "eq", + getChefsHostname(environment.baseURL), + ); + cy.location("pathname", { timeout }).should("include", "/app"); +} + +const isProd = + (Cypress.env("CHEFS_ENV") || Cypress.env("environment") || "").toLowerCase() === + "prod"; + +(isProd ? describe.skip : describe)("CHEFS Form Submission API", () => { + let apiConfig: ChefsApiConfig; + let submissionPayload: ChefsSubmissionPayload; + let environment: ChefsEnvironment; + let authToken: string; + let createdSubmissionId: string; + + before(() => { + const authTimeout = 60000; + + cy.readFile("cypress/scripts/chefs-api-config.json").then((config) => { + apiConfig = config; + + const envKey = ( + Cypress.env("CHEFS_ENV") || + Cypress.env("environment") || + "test" + ).toLowerCase(); + + environment = config.environments[envKey]; + + expect( + environment, + `Missing CHEFS environment configuration for '${envKey}'`, + ).to.exist; + + cy.log(`Using environment: ${envKey}`); + cy.log(`Base URL: ${environment.baseURL}`); + cy.log(`Form ID: ${environment.formId}`); + cy.log(`Version ID: ${environment.versionId}`); + + cy.readFile("cypress/scripts/chefs-submission-payload.json").then( + (payload) => { + submissionPayload = payload; + submissionPayload.submission.metadata.origin = environment.baseURL; + submissionPayload.submission.metadata.referrer = `${environment.baseURL}/app/form/submit?f=${environment.formId}`; + + cy.log( + `Payload loaded with ${ + Object.keys(payload.submission.data).length + } data fields`, + ); + cy.log(`Metadata origin set to: ${environment.baseURL}`); + }, + ); + + let capturedToken = ""; + + cy.intercept("**/app/api/v1/**", (req) => { + const authHeader = req.headers["authorization"] as string; + if (authHeader && !capturedToken) { + capturedToken = authHeader.replace(/^Bearer\s+/i, ""); + } + }).as("chefsApiCalls"); + + completeChefsLogin(environment, authTimeout); + visitChefsForm(environment, authTimeout); + + cy.window({ timeout: authTimeout }) + .should((win) => { + const tokenFromStorage = extractTokenFromStorage(win); + const resolvedToken = capturedToken || tokenFromStorage; + + expect( + resolvedToken, + "Waiting for authenticated CHEFS API token from request or browser storage", + ).to.not.equal(""); + + if (!capturedToken && tokenFromStorage) { + capturedToken = tokenFromStorage; + } + }) + .then(() => { + authToken = capturedToken; + cy.log("✅ Auth token captured from CHEFS login"); + }); + }); + }); + + it("should submit form via CHEFS API", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + + cy.log(`Submitting to: ${submissionUrl}`); + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: submissionPayload, + failOnStatusCode: false, + }).then((response) => { + cy.log(`Response Status: ${response.status}`); + cy.log( + `Response Body: ${JSON.stringify(response.body).substring(0, 200)}...`, + ); + + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - Token is expired or invalid"); + cy.log("📖 See cypress/scripts/README.md for token refresh instructions"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + + if (response.body.id) { + createdSubmissionId = response.body.id; + cy.log(`✅ Submission created with ID: ${response.body.id}`); + } + + expect(response.body).to.have.property("formVersionId", environment.versionId); + + if (response.body.formId) { + expect(response.body.formId).to.eq(environment.formId); + } else { + cy.log("⚠️ Response doesn't include formId (CHEFS version-dependent)"); + } + }); + }); + + it("should submit form with custom data overrides", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + const customPayload = JSON.parse(JSON.stringify(submissionPayload)); + + const timestamp = new Date().toISOString(); + customPayload.submission.data._ApplicantName = `AutoTest_${Date.now()}`; + customPayload.submission.data._projectTitle = `Automated Test Project ${timestamp}`; + customPayload.submission.data._ContactEmail = `autotest_${Date.now()}@example.com`; + customPayload.submission.data._totalProjectCost = 1000000; + customPayload.submission.data._fundingRequest = 750000; + + cy.log("Custom fields set:"); + cy.log(`- Applicant: ${customPayload.submission.data._ApplicantName}`); + cy.log(`- Project: ${customPayload.submission.data._projectTitle}`); + cy.log(`- Email: ${customPayload.submission.data._ContactEmail}`); + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: customPayload, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - Token is expired or invalid"); + cy.log("📖 See cypress/scripts/README.md for token refresh instructions"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + + if (response.body.id) { + cy.log(`✅ Custom submission created with ID: ${response.body.id}`); + } + }); + }); + + it("should handle draft submission", () => { + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + const draftPayload = JSON.parse(JSON.stringify(submissionPayload)); + + draftPayload.draft = true; + draftPayload.submission.state = "draft"; + draftPayload.submission.data._ApplicantName = `Draft_${Date.now()}`; + + cy.log("Submitting as DRAFT"); + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: draftPayload, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - Token is expired or invalid"); + cy.log("📖 See cypress/scripts/README.md for token refresh instructions"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + + if (response.body.draft !== undefined) { + expect(response.body.draft).to.be.true; + cy.log(`✅ Draft submission created with ID: ${response.body.id}`); + } + }); + }); + + it("should retrieve submission by ID", () => { + if (createdSubmissionId) { + const retrieveUrl = `${environment.baseURL}/app/api/v1/submissions/${createdSubmissionId}`; + + cy.request({ + method: "GET", + url: retrieveUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + }, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + cy.log("❌ 401 Unauthorized - CHEFS login credentials may be invalid"); + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.eq(200); + + if (response.body.submission) { + expect(response.body.submission).to.have.property("id", createdSubmissionId); + cy.log(`✅ Retrieved submission: ${createdSubmissionId}`); + } else if (response.body.id) { + expect(response.body.id).to.eq(createdSubmissionId); + cy.log(`✅ Retrieved submission: ${createdSubmissionId}`); + } else { + cy.log("⚠️ Unexpected response structure - logging for debugging"); + cy.log(JSON.stringify(response.body, null, 2)); + } + }); + } else { + cy.log("⚠️ Skipping - No submission ID available"); + } + }); + + it("should submit form with file attachment", () => { + const filePath = `${Cypress.config("projectRoot")}/cypress/fixtures/test-attachment.txt`; + + cy.task("uploadChefsFile", { + baseURL: environment.baseURL, + authToken: authToken, + filePath: filePath, + }).then((fileRef: any) => { + cy.log(`✅ File uploaded: ${JSON.stringify(fileRef)}`); + + const payloadWithFile = JSON.parse(JSON.stringify(submissionPayload)); + payloadWithFile.submission.data.simplefile = Array.isArray(fileRef) + ? fileRef + : [fileRef]; + + const submissionUrl = `${environment.baseURL}/app/api/v1/forms/${environment.formId}/versions/${environment.versionId}/submissions`; + + cy.request({ + method: "POST", + url: submissionUrl, + headers: { + ...apiConfig.headers, + Authorization: `Bearer ${authToken}`, + Origin: environment.baseURL, + Referer: `${environment.baseURL}/app/form/submit?f=${environment.formId}`, + }, + body: payloadWithFile, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 401) { + throw new Error( + "Authentication failed (401). Check that test1username/test1password credentials in cypress.env.json are valid and that the CHEFS UI login succeeded during test setup.", + ); + } + + expect(response.status).to.be.oneOf([200, 201]); + expect(response.body).to.have.property("id"); + cy.log(`✅ Submission with attachment created: ${response.body.id}`); + }); + }); + }); + + it("should update submission payload data and save back to file", () => { + const updatedPayload = JSON.parse(JSON.stringify(submissionPayload)); + + updatedPayload.submission.data._ApplicantName = "UpdatedApplicant"; + updatedPayload.submission.data._projectTitle = "Updated Project Title"; + + cy.writeFile( + "cypress/scripts/chefs-submission-payload-updated.json", + updatedPayload, + ); + + cy.log("✅ Updated payload saved to chefs-submission-payload-updated.json"); + }); +}); diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json new file mode 100644 index 0000000000..d7f02d0ff3 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload-updated.json @@ -0,0 +1,193 @@ +{ + "draft": false, + "submission": { + "data": { + "next6": false, + "acceptanceOfEligibilityCriteria": true, + "previous6": false, + "next7": true, + "_ApplicantName": "UpdatedApplicant", + "_organizationName": { + "type": "name", + "sub_type": "entity_name", + "value": "DEC DESIGN", + "topic_source_id": "FM0036035", + "topic_type": "registration.registries.ca", + "credential_type": "registration.registries.ca", + "credential_id": "9ed8e4ee-0209-4548-90f5-7a6846bf3d5a", + "score": 63.596394 + }, + "_dateExtractBusinessName": "DEC DESIGN", + "_hiddenOrganizationName": "DEC DESIGN", + "_registeredBusinessNumber": "FM0036035", + "_hiddenOrganizationNumber": "FM0036035", + "_OrganizationName": "VelangTest", + "_OrganizationType": "CORPORATION", + "_OrgBookStatus": "HISTORICAL", + "_riskRanking": "MEDIUM", + "sector": { + "SectorCode": "21", + "SectorName": "Mining, quarrying, and oil and gas extraction", + "SubSectors": [ + { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + { + "SubSectorCode": "212", + "SubSectorName": "Mining and quarrying (except oil and gas)" + }, + { + "SubSectorCode": "213", + "SubSectorName": "Support activities for mining, and oil and gas extraction" + }, + { + "SubSectorCode": "0", + "SubSectorName": "Other" + } + ] + }, + "_hiddenSector": "Mining, quarrying, and oil and gas extraction", + "_hiddenSubsector": "Oil and gas extraction", + "_ContactName": "VelangTest", + "_ContactTitle": "VelangTest", + "_ContactEmail": "VelangTest@VelangTest.cVelangTest", + "_ContactPhoneNumberPrimary": "(987) 654-6545", + "_ContactPhoneNumberSecondary": "(321) 321-3213", + "_MailingAddressUnit": "", + "_MailingAddressStreet1": "11995 Haney Place", + "_MailingAddressStreet2": "", + "_MailingAddressCity": "Maple Ridge", + "_MailingAddressProvince": "British Columbia", + "_MailingAddressCountry": "Canada", + "_MailingAddressPostalCode": "V2X 6G2", + "_PhysicalAddressUnit": "", + "_PhysicalAddressStreet1": "11995 Haney Place", + "_PhysicalAddressStreet2": "", + "_PhysicalAddressCity": "Maple Ridge", + "_PhysicalAddressProvince": "British Columbia", + "_PhysicalAddressCountry": "Canada", + "_PhysicalAddressPostalCode": "V2X 6G2", + "_SigningAuthorityName": "VelangTest", + "_signatoryTitle1": "Chief Executive Officer (CEO)", + "_SigningAuthorityEmail": "VelangTest@VelangTest.com", + "_SigningAuthorityPhoneNumberPrimary": "(987) 564-3211", + "_SigningAuthorityPhoneNumberSecondary": "", + "simplefile": [], + "previous7": false, + "next8": true, + "_projectTitle": "Updated Project Title", + "pleaseBrieflyDescribeYourProject": "This project aims to develop sustainable resource extraction infrastructure in the Maple Ridge area, supporting local employment, improving community access to economic opportunities, and ensuring environmentally responsible operations in alignment with provincial standards.", + "projectLocationDetailed": { + "location": "Maple Ridge", + "place_name": "", + "community": "Maple Ridge", + "regional_district": "Metro Vancouver", + "economic_region": "Mainland/Southwest", + "rural_category": "Urban 1" + }, + "dataExtractEconomicRegion": "Mainland/Southwest", + "dataExtractRegionalDistrict": "Metro Vancouver", + "dataExtractCommunity": "Maple Ridge", + "dataExtractPlace": "", + "_EconomicRegionHidden": "Mainland/Southwest", + "_RegionalDistrictHidden": "Metro Vancouver", + "_CommunityHidden": "Maple Ridge", + "_placeHidden": "", + "_electoralDistrict": "Maple Ridge-Pitt Meadows", + "willOtherCommunitiesInBritishColumbiaDirectlyBenefit": "no", + "_Community1LocationHidden": "", + "_Community2LocationHidden": "", + "_Community3LocationHidden": "", + "hiddenComponentOtherCommunities": "", + "pleaseTellUsAboutTheCommunities": "Maple Ridge is a growing urban community in the Metro Vancouver Regional District with a population of approximately 80,000 residents. The community has an active local economy with significant opportunities in the resource sector, and residents would directly benefit from increased employment, improved local infrastructure, and expanded economic activity generated by this project.", + "whatCommunityNeedAreYouTryingToAddress": "The Maple Ridge community faces a need for sustainable economic diversification and skilled job creation in the resource sector. Current infrastructure limitations restrict the community's ability to attract investment and support local workers, resulting in residents commuting to other regions for employment opportunities in the oil and gas extraction industry.", + "whatAreTheIntendedOutcomesOfTheProject": "The project intends to: (1) Create a minimum of 25 full-time skilled positions for local residents; (2) Establish compliant and environmentally responsible extraction operations; (3) Contribute to the local tax base, supporting municipal services; (4) Develop partnerships with Indigenous communities and local suppliers; and (5) Deliver measurable reductions in operational emissions through modern equipment and practices.", + "keyProjectActivities": "1. Site assessment and environmental impact studies (April–May 2026); 2. Procurement of equipment and contractor engagement (May–June 2026); 3. Infrastructure construction and installation (June–September 2026); 4. Hiring and training of local workforce (August–October 2026); 5. Commissioning and operational launch (November 2026); 6. Ongoing monitoring, reporting, and community engagement (November–December 2026).", + "_indigenousOwned": "False", + "_forestryOrNon-Forestry": "NON_FORESTRY", + "_acquisition": "YES", + "previous1": false, + "next2": true, + "_ProjectStartDate": "2026-04-01T00:00:00-07:00", + "_ProjectEndDate": "2026-12-31T00:00:00-08:00", + "previous2": false, + "next3": true, + "source1FundingDescription": "Federal Government", + "source2FundingDescription": "Economic Trust", + "previous5": false, + "next5": true, + "iHaveReadTheAttestationAboveAndAgreetoAllTermsTherein": true, + "iAmAuthorizedToSubmitThisApplication": true, + "submit": true, + "previous4": false, + "lateEntry": false, + "subsector": { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + "_communityPopulation": 80000, + "_approxNumberOfEmployees": 85, + "_totalProjectCost": 800000, + "_fundingRequest": 500000, + "source1AmountOfFunding": 80000, + "source2AmountOfFunding": 98000 + }, + "metadata": { + "selectData": { + "_MailingAddressProvince": { + "label": "British Columbia" + }, + "_MailingAddressCountry": { + "label": "Canada" + }, + "_PhysicalAddressProvince": { + "label": "British Columbia" + }, + "_PhysicalAddressCountry": { + "label": "Canada" + }, + "_OrganizationType": { + "label": "Corporation" + }, + "_OrgBookStatus": { + "label": "Historical" + }, + "_riskRanking": { + "label": "Medium" + }, + "_signatoryTitle1": { + "label": "Chief Executive Officer (CEO)" + }, + "_electoralDistrict": { + "label": "Maple Ridge-Pitt Meadows" + }, + "_indigenousOwned": { + "label": "No" + }, + "_forestryOrNon-Forestry": { + "label": "Non-Forestry" + }, + "_acquisition": { + "label": "Yes" + }, + "source1FundingDescription": { + "label": "Federal Government" + }, + "source2FundingDescription": { + "label": "Economic Trust" + } + }, + "timezone": "America/Vancouver", + "offset": -480, + "origin": "https://chefs-test.apps.silver.devops.gov.bc.ca", + "referrer": "https://chefs-test.apps.silver.devops.gov.bc.ca/app/form/submit?f=f2f45aa7-62c5-49ca-8846-b214e02adb46", + "browserName": "Netscape", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "pathName": "/app/form/submit", + "onLine": true + }, + "state": "submitted", + "_vnote": "" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json new file mode 100644 index 0000000000..eb7a72973e --- /dev/null +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-submission-payload.json @@ -0,0 +1,193 @@ +{ + "draft": false, + "submission": { + "data": { + "next6": false, + "acceptanceOfEligibilityCriteria": true, + "previous6": false, + "next7": true, + "_ApplicantName": "VelangTest2", + "_organizationName": { + "type": "name", + "sub_type": "entity_name", + "value": "DEC DESIGN", + "topic_source_id": "FM0036035", + "topic_type": "registration.registries.ca", + "credential_type": "registration.registries.ca", + "credential_id": "9ed8e4ee-0209-4548-90f5-7a6846bf3d5a", + "score": 63.596394 + }, + "_dateExtractBusinessName": "DEC DESIGN", + "_hiddenOrganizationName": "DEC DESIGN", + "_registeredBusinessNumber": "FM0036035", + "_hiddenOrganizationNumber": "FM0036035", + "_OrganizationName": "VelangTest", + "_OrganizationType": "CORPORATION", + "_OrgBookStatus": "HISTORICAL", + "_riskRanking": "MEDIUM", + "sector": { + "SectorCode": "21", + "SectorName": "Mining, quarrying, and oil and gas extraction", + "SubSectors": [ + { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + { + "SubSectorCode": "212", + "SubSectorName": "Mining and quarrying (except oil and gas)" + }, + { + "SubSectorCode": "213", + "SubSectorName": "Support activities for mining, and oil and gas extraction" + }, + { + "SubSectorCode": "0", + "SubSectorName": "Other" + } + ] + }, + "_hiddenSector": "Mining, quarrying, and oil and gas extraction", + "_hiddenSubsector": "Oil and gas extraction", + "_ContactName": "VelangTest", + "_ContactTitle": "VelangTest", + "_ContactEmail": "VelangTest@VelangTest.cVelangTest", + "_ContactPhoneNumberPrimary": "(987) 654-6545", + "_ContactPhoneNumberSecondary": "(321) 321-3213", + "_MailingAddressUnit": "", + "_MailingAddressStreet1": "11995 Haney Place", + "_MailingAddressStreet2": "", + "_MailingAddressCity": "Maple Ridge", + "_MailingAddressProvince": "British Columbia", + "_MailingAddressCountry": "Canada", + "_MailingAddressPostalCode": "V2X 6G2", + "_PhysicalAddressUnit": "", + "_PhysicalAddressStreet1": "11995 Haney Place", + "_PhysicalAddressStreet2": "", + "_PhysicalAddressCity": "Maple Ridge", + "_PhysicalAddressProvince": "British Columbia", + "_PhysicalAddressCountry": "Canada", + "_PhysicalAddressPostalCode": "V2X 6G2", + "_SigningAuthorityName": "VelangTest", + "_signatoryTitle1": "Chief Executive Officer (CEO)", + "_SigningAuthorityEmail": "VelangTest@VelangTest.com", + "_SigningAuthorityPhoneNumberPrimary": "(987) 564-3211", + "_SigningAuthorityPhoneNumberSecondary": "", + "simplefile": [], + "previous7": false, + "next8": true, + "_projectTitle": "Maple Ridge Community Resource Development Initiative", + "pleaseBrieflyDescribeYourProject": "This project aims to develop sustainable resource extraction infrastructure in the Maple Ridge area, supporting local employment, improving community access to economic opportunities, and ensuring environmentally responsible operations in alignment with provincial standards.", + "projectLocationDetailed": { + "location": "Maple Ridge", + "place_name": "", + "community": "Maple Ridge", + "regional_district": "Metro Vancouver", + "economic_region": "Mainland/Southwest", + "rural_category": "Urban 1" + }, + "dataExtractEconomicRegion": "Mainland/Southwest", + "dataExtractRegionalDistrict": "Metro Vancouver", + "dataExtractCommunity": "Maple Ridge", + "dataExtractPlace": "", + "_EconomicRegionHidden": "Mainland/Southwest", + "_RegionalDistrictHidden": "Metro Vancouver", + "_CommunityHidden": "Maple Ridge", + "_placeHidden": "", + "_electoralDistrict": "Maple Ridge-Pitt Meadows", + "willOtherCommunitiesInBritishColumbiaDirectlyBenefit": "no", + "_Community1LocationHidden": "", + "_Community2LocationHidden": "", + "_Community3LocationHidden": "", + "hiddenComponentOtherCommunities": "", + "pleaseTellUsAboutTheCommunities": "Maple Ridge is a growing urban community in the Metro Vancouver Regional District with a population of approximately 80,000 residents. The community has an active local economy with significant opportunities in the resource sector, and residents would directly benefit from increased employment, improved local infrastructure, and expanded economic activity generated by this project.", + "whatCommunityNeedAreYouTryingToAddress": "The Maple Ridge community faces a need for sustainable economic diversification and skilled job creation in the resource sector. Current infrastructure limitations restrict the community's ability to attract investment and support local workers, resulting in residents commuting to other regions for employment opportunities in the oil and gas extraction industry.", + "whatAreTheIntendedOutcomesOfTheProject": "The project intends to: (1) Create a minimum of 25 full-time skilled positions for local residents; (2) Establish compliant and environmentally responsible extraction operations; (3) Contribute to the local tax base, supporting municipal services; (4) Develop partnerships with Indigenous communities and local suppliers; and (5) Deliver measurable reductions in operational emissions through modern equipment and practices.", + "keyProjectActivities": "1. Site assessment and environmental impact studies (April–May 2026); 2. Procurement of equipment and contractor engagement (May–June 2026); 3. Infrastructure construction and installation (June–September 2026); 4. Hiring and training of local workforce (August–October 2026); 5. Commissioning and operational launch (November 2026); 6. Ongoing monitoring, reporting, and community engagement (November–December 2026).", + "_indigenousOwned": "False", + "_forestryOrNon-Forestry": "NON_FORESTRY", + "_acquisition": "YES", + "previous1": false, + "next2": true, + "_ProjectStartDate": "2026-04-01T00:00:00-07:00", + "_ProjectEndDate": "2026-12-31T00:00:00-08:00", + "previous2": false, + "next3": true, + "source1FundingDescription": "Federal Government", + "source2FundingDescription": "Economic Trust", + "previous5": false, + "next5": true, + "iHaveReadTheAttestationAboveAndAgreetoAllTermsTherein": true, + "iAmAuthorizedToSubmitThisApplication": true, + "submit": true, + "previous4": false, + "lateEntry": false, + "subsector": { + "SubSectorCode": "211", + "SubSectorName": "Oil and gas extraction" + }, + "_communityPopulation": 80000, + "_approxNumberOfEmployees": 85, + "_totalProjectCost": 800000, + "_fundingRequest": 500000, + "source1AmountOfFunding": 80000, + "source2AmountOfFunding": 98000 + }, + "metadata": { + "selectData": { + "_MailingAddressProvince": { + "label": "British Columbia" + }, + "_MailingAddressCountry": { + "label": "Canada" + }, + "_PhysicalAddressProvince": { + "label": "British Columbia" + }, + "_PhysicalAddressCountry": { + "label": "Canada" + }, + "_OrganizationType": { + "label": "Corporation" + }, + "_OrgBookStatus": { + "label": "Historical" + }, + "_riskRanking": { + "label": "Medium" + }, + "_signatoryTitle1": { + "label": "Chief Executive Officer (CEO)" + }, + "_electoralDistrict": { + "label": "Maple Ridge-Pitt Meadows" + }, + "_indigenousOwned": { + "label": "No" + }, + "_forestryOrNon-Forestry": { + "label": "Non-Forestry" + }, + "_acquisition": { + "label": "Yes" + }, + "source1FundingDescription": { + "label": "Federal Government" + }, + "source2FundingDescription": { + "label": "Economic Trust" + } + }, + "timezone": "America/Vancouver", + "offset": -480, + "origin": "", + "referrer": "", + "browserName": "Netscape", + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", + "pathName": "/app/form/submit", + "onLine": true + }, + "state": "submitted", + "_vnote": "" + } +} \ No newline at end of file diff --git a/applications/Unity.AutoUI/cypress/support/commands.ts b/applications/Unity.AutoUI/cypress/support/commands.ts index 686e928f52..7216c27660 100644 --- a/applications/Unity.AutoUI/cypress/support/commands.ts +++ b/applications/Unity.AutoUI/cypress/support/commands.ts @@ -62,9 +62,9 @@ Cypress.Commands.add("getSubmissionDetail", (key: string) => { return cy .fixture<{ submissionDetails: SubmissionDetail[] }>("submissions.json") .then(({ submissionDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = submissionDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -86,9 +86,9 @@ Cypress.Commands.add("getMetabaseDetail", (key: string) => { return cy .fixture<{ metabaseDetails: MetabaseDetail[] }>("metabase.json") .then(({ metabaseDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = metabaseDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -125,9 +125,9 @@ Cypress.Commands.add("getChefsDetail", (key: string) => { return cy .fixture<{ chefsDetails: chefsDetail[] }>("chefs.json") .then(({ chefsDetails }) => { - const environment = Cypress.env("environment"); + const environment = Cypress.env("environment")?.toUpperCase(); const submissionDetail = chefsDetails.find( - (detail) => detail.unityEnv === environment, + (detail) => detail.unityEnv.toUpperCase() === environment, ); if (submissionDetail && submissionDetail.hasOwnProperty(key)) { @@ -196,3 +196,163 @@ Cypress.Commands.add("clearBrowserCache", () => { }); }); }); + +// ============ Dynamic Submission Fetching ============ + +// Use interfaces from index.d.ts - only define API response wrapper here +interface GrantApplicationResponse { + items: GrantApplication[]; + totalCount: number; +} + +/** + * Fetches a dynamic submission ID (referenceNo) from the API after login. + * Uses session cookies automatically from Cypress. + * Results are sorted by submissionDate descending (latest first) by default. + * + * @param options - Optional filters for selecting submissions + * @returns Chainable containing the referenceNo (e.g., "209BD469") + * + * @example + * // Get latest submission from "Data Seeder" category + * cy.fetchDynamicSubmission({ categoryFilter: 'Data Seeder' }).then((id) => { ... }) + * + * @example + * // Get latest "Submitted" submission + * cy.fetchDynamicSubmission({ statusFilter: ['Submitted'] }).then((id) => { ... }) + * + * @example + * // Get second-latest submission from specific category + * cy.fetchDynamicSubmission({ categoryFilter: 'Data Seeder', index: 1 }).then((id) => { ... }) + * + * Available status values: 'Submitted', 'Under Assessment', 'Approved', 'Closed', 'Deferred' + */ +function fetchGrantApplications(): Cypress.Chainable { + const apiUrl = `${Cypress.env("webapp.url")}api/app/grant-application`; + return cy.getCookie("XSRF-TOKEN").then((xsrfCookie) => { + return cy + .request({ + method: "GET", + url: apiUrl, + qs: { submittedFromDate: "", submittedToDate: "" }, + headers: { + Accept: "application/json, text/javascript, */*; q=0.01", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + RequestVerificationToken: xsrfCookie?.value || "", + }, + failOnStatusCode: false, + }) + .then((response) => { + if (response.status !== 200) { + throw new Error( + `API request failed with status ${response.status}: ${JSON.stringify(response.body)}` + ); + } + const data = response.body as GrantApplicationResponse; + Cypress.log({ name: "fetch", message: `📋 Fetched ${data.items?.length || 0} applications` }); + return data.items || []; + }); + }); +} + +Cypress.Commands.add( + "fetchDynamicSubmission", + (options: FetchSubmissionOptions = {}) => { + return fetchGrantApplications().then((allApplications) => { + let applications = allApplications; + + Cypress.log({ name: "fetch", message: `📋 Fetched ${applications.length} applications from API` }); + + // Filter by category if specified (e.g., 'Data Seeder') + if (options.categoryFilter) { + applications = applications.filter((app) => + app.category === options.categoryFilter + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with category: ${options.categoryFilter}`, + }); + } + + // Filter by status if specified (e.g., 'Submitted', 'Under Assessment', 'Approved') + if (options.statusFilter && options.statusFilter.length > 0) { + applications = applications.filter((app) => + options.statusFilter!.includes(app.status) + ); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications with status: ${options.statusFilter.join(", ")}`, + }); + } + + // Filter by max age if specified + if (options.maxAge) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - options.maxAge); + applications = applications.filter((app) => { + const submissionDate = new Date(app.submissionDate); + return submissionDate >= cutoffDate; + }); + Cypress.log({ + name: "filter", + message: `📋 Filtered to ${applications.length} applications within ${options.maxAge} days`, + }); + } + + if (applications.length === 0) { + throw new Error( + "No applications found matching the specified criteria" + ); + } + + // Sort applications (default: by submissionDate descending for latest first) + const sortBy = options.sortBy || 'submissionDate'; + const sortOrder = options.sortOrder || 'desc'; + applications.sort((a, b) => { + let aVal: number | string; + let bVal: number | string; + + if (sortBy === 'submissionDate') { + aVal = new Date(a.submissionDate).getTime(); + bVal = new Date(b.submissionDate).getTime(); + } else { + aVal = a[sortBy] as number; + bVal = b[sortBy] as number; + } + + if (sortOrder === 'desc') { + return bVal > aVal ? 1 : bVal < aVal ? -1 : 0; + } else { + return aVal > bVal ? 1 : aVal < bVal ? -1 : 0; + } + }); + + // Get the submission at the specified index (default: 0 = first/latest) + const index = options.index || 0; + if (index >= applications.length) { + throw new Error( + `Index ${index} out of range. Only ${applications.length} applications available.` + ); + } + + const selectedApp = applications[index]; + Cypress.log({ + name: "selected", + message: `✅ Selected submission: ${selectedApp.referenceNo} (Status: ${selectedApp.status}, Category: ${selectedApp.category})`, + }); + + return selectedApp.referenceNo; + }); + } +); + +/** + * Fetches all available submissions from the API. + * Useful for selecting a specific submission based on custom criteria. + * + * @returns Chainable containing array of grant applications + */ +Cypress.Commands.add("fetchAllSubmissions", () => { + return fetchGrantApplications(); +}); diff --git a/applications/Unity.AutoUI/cypress/support/e2e.ts b/applications/Unity.AutoUI/cypress/support/e2e.ts index 860eac1b25..0b9acad7b3 100644 --- a/applications/Unity.AutoUI/cypress/support/e2e.ts +++ b/applications/Unity.AutoUI/cypress/support/e2e.ts @@ -8,12 +8,24 @@ import '../support/commands' -// Ignore ResizeObserver loop errors - these are benign browser notifications -// that occur when ResizeObserver callbacks don't complete in a single animation frame +// Ignore common errors that shouldn't fail tests Cypress.on('uncaught:exception', (err) => { + // ResizeObserver loop errors - benign browser notifications if (err.message.includes('ResizeObserver loop')) { return false } - // Return true to fail the test for other errors + // Network errors that can occur during navigation + if (err.message.includes('Network Error') || err.message.includes('net::ERR')) { + return false + } + // Script errors from third-party resources + if (err.message.includes('Script error')) { + return false + } + // Chunk loading errors + if (err.message.includes('Loading chunk') || err.message.includes('ChunkLoadError')) { + return false + } + // Return true to fail tests on unexpected uncaught exceptions return true }) diff --git a/applications/Unity.AutoUI/cypress/support/index.d.ts b/applications/Unity.AutoUI/cypress/support/index.d.ts index ad8fa26a5e..979f84c4a5 100644 --- a/applications/Unity.AutoUI/cypress/support/index.d.ts +++ b/applications/Unity.AutoUI/cypress/support/index.d.ts @@ -6,17 +6,107 @@ // https://on.cypress.io/configuration // *********************************************************** -declare namespace Cypress { - interface Chainable { - login(): void; // Custom command to login to Unity - logout(): void; // Custom command to log out of Unity - getSubmissionDetail(key: string): Chainable; // Custom command to get submission details by key for the current environment. - getMetabaseDetail(key: string): Chainable; // Custom command to get metabase details by key for the current environment. - metabaseLogin(): Chainable; // Custom command to login to Metabase - getChefsDetail(key: string): Chainable; // Custom command to get chefs details by key for the current environment. - chefsLogin(): Chainable; // Custom command to login to Chefs - chefsLogout(): Chainable; // Custom command to log out of Chefs - clearSessionStorage(): Chainable; // Custom command to clear session storage. - clearBrowserCache(): Chainable; // Custom command to clear browser cache. +/** + * Grant Application interface matching actual API response + * from /api/app/grant-application endpoint + */ +interface GrantApplication { + id: string; + /** The submission reference number displayed in UI (e.g., "209BD469") */ + referenceNo: string; + /** Application status (e.g., "Submitted", "Approved", "Under Assessment", "Closed", "Deferred") */ + status: string; + /** ISO date string of submission */ + submissionDate: string; + /** Project name */ + projectName: string; + /** Organization name */ + organizationName: string; + /** Requested funding amount */ + requestedAmount: number; + /** Approved funding amount */ + approvedAmount: number; + /** Category/program name */ + category: string; + /** City */ + city: string; + /** Additional fields from API */ + [key: string]: unknown; +} + +/** + * Options for fetching dynamic submissions + */ +interface FetchSubmissionOptions { + /** Filter by status values (e.g., ['Submitted', 'Under Assessment']) */ + statusFilter?: string[]; + /** Filter by category/program name (e.g., 'Data Seeder') */ + categoryFilter?: string; + /** Max age in days (default: no limit) */ + maxAge?: number; + /** Sort by field (default: 'submissionDate') */ + sortBy?: 'submissionDate' | 'requestedAmount' | 'approvedAmount'; + /** Sort order (default: 'desc' for latest first) */ + sortOrder?: 'asc' | 'desc'; + /** Which submission to return after sorting (default: 0 = first/latest) */ + index?: number; +} + +declare namespace Cypress { + interface Chainable { + /** Custom command to login to Unity */ + login(): void; + + /** Custom command to log out of Unity */ + logout(): void; + + /** Custom command to get submission details by key for the current environment */ + getSubmissionDetail(key: string): Chainable; + + /** Custom command to get metabase details by key for the current environment */ + getMetabaseDetail(key: string): Chainable; + + /** Custom command to login to Metabase */ + metabaseLogin(): Chainable; + + /** Custom command to get chefs details by key for the current environment */ + getChefsDetail(key: string): Chainable; + + /** Custom command to login to Chefs */ + chefsLogin(): Chainable; + + /** Custom command to log out of Chefs */ + chefsLogout(): Chainable; + + /** Custom command to clear session storage */ + clearSessionStorage(): Chainable; + + /** Custom command to clear browser cache */ + clearBrowserCache(): Chainable; + + /** + * Fetches a dynamic submission ID from the API after login. + * Uses session cookies automatically from Cypress. + * + * @param options - Optional filters for selecting submissions + * @returns Chainable containing the confirmation ID + * + * @example + * // Get first available submission + * cy.fetchDynamicSubmission().then((id) => { ... }) + * + * @example + * // Get second submission with specific status + * cy.fetchDynamicSubmission({ statusFilter: ['SUBMITTED'], index: 1 }).then((id) => { ... }) + */ + fetchDynamicSubmission(options?: FetchSubmissionOptions): Chainable; + + /** + * Fetches all available submissions from the API. + * Useful for selecting a specific submission based on custom criteria. + * + * @returns Chainable containing array of grant applications + */ + fetchAllSubmissions(): Chainable; } -} \ No newline at end of file +} diff --git a/applications/Unity.AutoUI/package.json b/applications/Unity.AutoUI/package.json index 380421fbe4..76aa67a0f5 100644 --- a/applications/Unity.AutoUI/package.json +++ b/applications/Unity.AutoUI/package.json @@ -1,9 +1,19 @@ { + "scripts": { + "test": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/e2e/**/*.cy.ts' --browser chrome", + "test:e2e": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/e2e/**/*.cy.ts' --browser chrome", + "test:regression-headed": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/**/*.cy.ts' --headed --browser chrome", + "test:regression-headless": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/**/*.cy.ts' --headless --browser chrome", + "test:open": "env -u ELECTRON_RUN_AS_NODE cypress open --browser chrome", + "test:seed": "env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/scripts/chefs-api-submission.cy.ts' --browser chrome", + "test:approval-flow": "npm run test:seed && env -u ELECTRON_RUN_AS_NODE cypress run --spec 'cypress/regression/ApprovalFlow.cy.ts' --headless --browser chrome" + }, "dependencies": { "form-data": "^4.0.5", "typescript": "^5.8.3" }, "devDependencies": { - "cypress": "^15.8.1" + "@types/node": "^25.4.0", + "cypress": "15.12.0" } } diff --git a/applications/Unity.AutoUI/tsconfig.json b/applications/Unity.AutoUI/tsconfig.json index 89a0c8e415..75780a90bf 100644 --- a/applications/Unity.AutoUI/tsconfig.json +++ b/applications/Unity.AutoUI/tsconfig.json @@ -106,5 +106,11 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, // Included or excluded files or folders... https://www.typescriptlang.org/tsconfig - "include": ["cypress/support/**/*.ts", "cypress/e2e/**/*.ts", "cypress/pages/**/*.ts"] + "include": [ + "cypress/support/**/*.ts", + "cypress/e2e/**/*.ts", + "cypress/pages/**/*.ts", + "cypress/regression/**/*.ts", + "cypress/scripts/**/*.ts" + ] } diff --git a/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md b/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md new file mode 100644 index 0000000000..bd78117a88 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/pre-readiness-deep.agent.md @@ -0,0 +1,262 @@ +--- +name: pr-readiness-deep +description: Deep PR quality gate that actively scans and fixes SonarQube issues, CodeQL vulnerabilities, and ABP architecture violations. +--- + +# PR Readiness Agent (Deep Scan) + +Final quality gate for Unity Grant Manager PRs with active issue detection and remediation. + +## Inputs +- Branch diff, build/test status, target branch + +## Core Capabilities +- **Active SonarQube scanning**: Use `sonarqube_analyze_file` to scan modified files +- **Security issue detection**: Use `sonarqube_list_potential_security_issues` for security hotspots +- **Automatic fixes**: Apply fixes for detected SonarQube and CodeQL issues +- **ABP validation**: Verify architecture compliance +- **Cypress E2E testing**: Run frontend integration tests from Unity.AutoUI project + +## Quality Checks Workflow + +### Step 1: Analyze Modified Files +For each changed file in the PR: +1. **Run SonarQube analysis**: `sonarqube_analyze_file` on the file +2. **Parse results**: Identify critical/blocker/major issues +3. **Fix issues automatically**: Apply fixes for common patterns +4. **Re-scan**: Verify fixes resolved the issues + +### Step 2: Security Scan +1. **List security issues**: `sonarqube_list_potential_security_issues` +2. **Check CodeQL alerts**: Review GitHub Security tab findings +3. **Prioritize**: Critical > High > Medium +4. **Fix**: Apply security patches + +### Step 3: ABP Architecture +- Layer boundaries (Domain → Application → Web) +- Repository/DTO/AutoMapper conventions +- Permissions and localization keys +- EF migrations (if schema changes) + +### Step 4: Build & Tests +```bash +# Backend build and unit tests +dotnet build Unity.GrantManager.sln --no-restore +dotnet test Unity.GrantManager.sln --no-build + +# Frontend Cypress E2E tests +cd ../Unity.AutoUI +npm install +npx cypress run +``` + +### Step 5: Cypress E2E Testing +1. **Navigate to Cypress project**: `cd applications/Unity.AutoUI` +2. **Run tests**: Use `npx cypress run` for headless, `npx cypress open` for interactive +3. **Parse results**: Check for failed tests, screenshots, videos +4. **Report**: Include test pass/fail status in output + +## SonarQube Issues to Auto-Fix + +**Critical/Blocker (Must Fix)**: +- SQL injection → Convert to EF LINQ +- Missing `[Authorize]` → Add authorization attributes +- Exposing domain entities → Convert to DTO pattern +- Hardcoded credentials → Move to configuration +- Resource leaks → Add proper disposal + +**Major (Should Fix)**: +- High complexity → Extract methods +- Code duplication → Create shared utilities +- Empty catch blocks → Add proper logging + +**Process**: +1. Use `sonarqube_analyze_file` on each modified file +2. Parse issue severity, rule, and location +3. Apply appropriate fix pattern (see Common Fixes below) +4. Re-analyze to confirm resolution + +### CodeQL Security (Check GitHub Security Tab) +**Must Fix (Critical/High)**: +- SQL injection +- Path traversal → Validate file paths +- Missing authorization +- Logging sensitive data +- Hardcoded secrets + +**After fixes**: Verify alerts cleared in GitHub Security tab + +## Action Mode + +When invoked: +1. **Scan all changed files** using `sonarqube_analyze_file` +2. **List security issues** using `sonarqube_list_potential_security_issues` +3. **Apply fixes** for detected issues using patterns below +4. **Run backend tests**: `dotnet test Unity.GrantManager.sln --no-build` +5. **Run Cypress E2E tests**: Navigate to Unity.AutoUI and run `npx cypress run` +6. **Validate**: Re-run analysis to confirm resolution +7. **Report**: Summary of issues found/fixed and test results + +## Output + +**After Scanning & Fixing**: +1. **Summary Report**: + - Files analyzed: X + - Issues found: Y (Critical: Z, High: W) + - Issues fixed: N + - Remaining issues: M + - Backend tests: X passed, Y failed + - Cypress E2E tests: X passed, Y failed (with links to screenshots/videos if failures) +2. **Go/No-Go Decision**: + - ✅ GO: No critical/blocker issues remain, all tests pass + - ❌ NO-GO: Critical issues, test failures, or security vulnerabilities require intervention + - ⚠️ CONDITIONAL: Minor issues present but can merge with follow-up tasks +3. **Detailed Findings**: + - Fixed automatically: List with file:line + - Manual review needed: List with reasoning +4. **Quality Metrics**: + - SonarQube gate: Pass/Fail + - CodeQL alerts: Count by severity + - Code coverage: % + - Build/test status: Pass/Fail + - **Cypress E2E tests**: Pass/Fail (X passed, Y failed) + - **Cypress artifacts**: Screenshots/videos if failures + +## Requirements + +- ✅ All SonarQube critical/blocker issues auto-fixed +- ✅ Security rating A/B (after fixes) +- ✅ No CodeQL critical/high vulnerabilities +- ✅ Code coverage ≥80% +- ✅ Build/tests pass (backend unit tests) +- ✅ **Cypress E2E tests pass** (frontend integration tests) +- ✅ ABP conventions followed +- ✅ AutoMapper/localization/permissions configured + +## Tool Usage + +### Analyzing Files +``` +Use sonarqube_analyze_file for each modified C# file to detect: +- Code quality issues +- Security vulnerabilities +- Maintainability problems +- Bug risks +``` + +### Listing Security Issues +``` +Use sonarqube_list_potential_security_issues to get: +- All security hotspots +- Vulnerabilities by severity +- Recommended fixes +``` + +### After Fixes +Re-run `sonarqube_analyze_file` on modified files to verify issues resolved. + +## Cypress E2E Testing + +### Location +- **Project**: `applications/Unity.AutoUI` +- **Config**: `cypress.config.ts` +- **Tests**: `cypress/` folder +- **Launcher**: `CypressTestLauncher.bat` (Windows) + +### Running Tests + +**Headless (CI/CD)**: +```bash +cd applications/Unity.AutoUI +npx cypress run +``` + +**Interactive Mode**: +```bash +cd applications/Unity.AutoUI +npx cypress open +``` + +**Using Launcher** (Windows): +```bash +cd applications/Unity.AutoUI +./CypressTestLauncher.bat +``` + +### What to Check +- ✅ All test specs pass +- ✅ No failed assertions +- ✅ Screenshots captured for failures (in `cypress/screenshots/`) +- ✅ Videos recorded (in `cypress/videos/`) +- ✅ No console errors or warnings +- ✅ UI rendering correctly + +### Failure Handling +If Cypress tests fail: +1. Review failure screenshots/videos +2. Check if UI changes broke existing tests +3. Update test selectors if component structure changed +4. Fix actual bugs if tests caught regressions +5. Re-run tests to verify fixes + +### Test Coverage Areas +Based on Unity.AutoUI project, tests likely cover: +- Grant application submission workflows +- Form validation +- User authentication/authorization +- Data table interactions +- File upload/download +- Multi-step wizards + +## Common Fixes + +```csharp +// ❌ SQL Injection +var sql = $"SELECT * FROM Users WHERE Email = '{email}'"; + +// ✅ Use EF LINQ +var users = await _dbContext.Users.Where(u => u.Email == email).ToListAsync(); + +// ❌ Missing authorization +public async Task DeleteAsync(Guid id) + +// ✅ Add attribute +[Authorize(GrantManagerPermissions.Applications.Delete)] +public async Task DeleteAsync(Guid id) + +// ❌ Return entity +public async Task GetAsync(Guid id) + +// ✅ Return DTO +public async Task GetAsync(Guid id) +{ + var entity = await _repository.GetAsync(id); + return ObjectMapper.Map(entity); +} + +// ❌ Path traversal +public async Task GetDocumentAsync(string fileName) +{ + var path = Path.Combine(root, "Documents", fileName); + return await File.ReadAllBytesAsync(path); +} + +// ✅ Validate path +public async Task GetDocumentAsync(Guid documentId) +{ + var doc = await _repository.GetAsync(documentId); + var safeFileName = Path.GetFileName(doc.FileName); + var fullPath = Path.GetFullPath(Path.Combine(root, "Documents", safeFileName)); + var allowedPath = Path.GetFullPath(Path.Combine(root, "Documents")); + + if (!fullPath.StartsWith(allowedPath)) + throw new BusinessException("Invalid path"); + + return await File.ReadAllBytesAsync(fullPath); +} +``` + +## References +- `.github/copilot-instructions.md` +- `.github/skills/unity-module-structure/SKILL.md` +- `.github/agents/unity-abp-instructions.md` diff --git a/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md b/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md new file mode 100644 index 0000000000..573577e9a5 --- /dev/null +++ b/applications/Unity.GrantManager/.github/agents/unity-abp-instructions.md @@ -0,0 +1,662 @@ +# ABP Framework Instructions for Unity Grant Manager + +## Project Overview +Unity Grant Manager is an ASP.NET Core MVC application built using ABP Framework 9.1.3, following Domain-Driven Design (DDD) principles. + +## Architecture & Technology Stack + +### Backend +- **Framework**: ABP Framework 9.1.3 (ASP.NET Core MVC) +- **Architecture**: Domain-Driven Design (DDD) +- **Pattern**: Multi-layered application (Domain, Application, Web) +- **UI Framework**: ABP MVC UI with Bootstrap 4 +- **ORM**: Entity Framework Core (inferred from ABP standard) + +### Frontend +- **UI Theme**: ABP Basic Theme (`@abp/aspnetcore.mvc.ui.theme.basic`) +- **JavaScript**: jQuery, DataTables.net +- **Form Builder**: FormIO (formiojs 4.17.4) +- **Charts**: ECharts 6.0 +- **Rich Text**: TinyMCE 8.3.2 +- **CSS**: Bootstrap 4.6.2 + +## Project Structure + +``` +Unity.GrantManager/ +├── src/ +│ ├── Unity.GrantManager.Domain/ # Domain layer (entities, aggregates, repositories) +│ ├── Unity.GrantManager.Domain.Shared/ # Shared domain concepts +│ ├── Unity.GrantManager.Application/ # Application services +│ ├── Unity.GrantManager.Application.Contracts/ # DTOs, interfaces +│ ├── Unity.GrantManager.Web/ # MVC UI layer +│ │ ├── Views/ # Razor views +│ │ │ └── Shared/Components/ # View components +│ │ ├── Controllers/ # MVC controllers +│ │ ├── wwwroot/ # Static files +│ │ └── Pages/ # Razor pages +│ └── Unity.GrantManager.HttpApi/ # Web API controllers +├── test/ # Test projects +└── modules/ # ABP modules +``` + +## ABP Framework Conventions + +### 1. Application Services +- Located in `*.Application` project +- Inherit from `ApplicationService` base class +- Use `AppService` suffix (e.g., `GrantApplicationAppService`) +- Return DTOs, not domain entities +- Handle authorization with `[Authorize]` attributes +- Use ABP's `IObjectMapper` for entity-to-DTO mapping + +```csharp +public class GrantApplicationAppService : ApplicationService, IGrantApplicationAppService +{ + private readonly IRepository _repository; + + public GrantApplicationAppService(IRepository repository) + { + _repository = repository; + } + + [Authorize(GrantManagerPermissions.GrantApplications.View)] + public async Task GetAsync(Guid id) + { + var entity = await _repository.GetAsync(id); + return ObjectMapper.Map(entity); + } +} +``` + +### 2. Domain Entities +- Located in `*.Domain` project +- Inherit from `Entity`, `AggregateRoot`, or `FullAuditedAggregateRoot` +- Use `FullAuditedAggregateRoot` for entities requiring audit trails +- Place business logic in entity methods, not in services +- Use domain events for cross-aggregate communication + +```csharp +public class GrantApplication : FullAuditedAggregateRoot +{ + public string ReferenceNo { get; private set; } + public decimal RequestedAmount { get; private set; } + public ApplicationStatus Status { get; private set; } + + public void Approve(decimal approvedAmount) + { + // Business logic here + Status = ApplicationStatus.Approved; + AddDistributedEvent(new ApplicationApprovedEvent(Id)); + } +} +``` + +### 3. Repositories +- Use `IRepository` for basic CRUD +- Create custom repositories in `*.Domain` for complex queries +- Repository interfaces in Domain, implementations in Infrastructure/EntityFrameworkCore + +### 4. DTOs (Data Transfer Objects) +- Located in `*.Application.Contracts` project +- Separate DTOs for create, update, and read operations +- Use `EntityDto` as base when including Id +- Example: `CreateGrantApplicationDto`, `UpdateGrantApplicationDto`, `GrantApplicationDto` + +### 5. MVC Controllers +- Located in `*.Web` project's `Controllers` folder +- Inherit from `AbpController` +- Use dependency injection for application services +- Return `IActionResult` or derived types +- Use ABP's localization: `L["KeyName"]` + +```csharp +public class GrantApplicationsController : AbpController +{ + private readonly IGrantApplicationAppService _applicationService; + + public GrantApplicationsController(IGrantApplicationAppService applicationService) + { + _applicationService = applicationService; + } + + public async Task Details(Guid applicationId) + { + var dto = await _applicationService.GetAsync(applicationId); + return View(dto); + } +} +``` + +### 6. View Components +- Located in `Views/Shared/Components/{ComponentName}/` +- Default view: `Default.cshtml` +- JavaScript file: `Default.js` (if needed) +- Invoke in views: `@await Component.InvokeAsync("ComponentName")` + +### 7. Localization +- Use `L` function in C#: `L["KeyName"]` +- Use `l` function in JavaScript: `l('KeyName')` +- Use `@L["KeyName"]` in Razor views +- Localization files in JSON format in `Localization` folder + +### 8. Permissions +- Define in `*Permissions.cs` files +- Use constants for permission names +- Check with `[Authorize(PermissionName)]` attribute or `await AuthorizationService.CheckAsync()` + +## Unity Grant Manager Specific Patterns + +### DataTables Integration +- Use `initializeDataTable()` helper function from `table-utils.js` +- Column definitions follow a consistent pattern with getter functions +- Enable server-side processing for large datasets +- Use `createNumberFormatter()` for currency formatting + +```javascript +const dataTable = initializeDataTable({ + dt: $('#TableId'), + defaultVisibleColumns: ['select', 'referenceNo', 'status'], + listColumns: getColumns(formatter, l), + maxRowsPerPage: 10, + defaultSortColumn: { name: 'submissionDate', dir: 'desc' }, + dataEndpoint: service.getList, + responseCallback: responseCallback, + actionButtons: actionButtons, + serverSideEnabled: true +}); +``` + +### Column Getter Pattern +- Create separate functions for each column definition +- Include `columnIndex` parameter for ordering +- Return object with: `title`, `data`, `name`, `className`, `render`, `index` + +```javascript +function getReferenceNoColumn(columnIndex) { + return { + title: 'Submission #', + data: 'referenceNo', + name: 'referenceNo', + className: 'data-table-header text-nowrap', + render: function (data, type, row) { + return `${data || ''}`; + }, + index: columnIndex + }; +} +``` + +### Form Handling +- Use ABP's form validation helpers +- Leverage FormIO for dynamic forms +- Handle form submission via AJAX with proper error handling + +### Date Handling +- Use `luxon` library for date manipulation +- Use ABP's `DateUtils.formatUtcDateToLocal()` helper +- Store dates in UTC, display in local timezone +- Format: `luxon.DateTime.fromISO(data).toUTC().toLocaleString()` + +### Currency Formatting +```javascript +const formatter = createNumberFormatter(); // From table-utils.js +formatter.format(amount); // Returns formatted currency string +``` + +## Best Practices + +### 1. Keep Business Logic in Domain +- Don't put business rules in controllers or views +- Use domain services for logic crossing multiple aggregates +- Application services orchestrate, domain entities execute + +### 2. Use ABP Conventions +- Follow ABP naming conventions (`AppService`, `Dto`, etc.) +- Use ABP's built-in features (authorization, localization, audit logging) +- Leverage ABP's dependency injection + +### 3. Maintain Layer Separation +- Domain layer has no dependencies on other layers +- Application layer depends only on Domain and Domain.Shared +- Web layer depends on Application.Contracts, not Domain directly + +### 4. Error Handling +- Use ABP's `UserFriendlyException` for user-facing errors +- Use ABP's `BusinessException` for business rule violations +- Let ABP's exception handling middleware manage responses + +### 5. JavaScript Organization +- Keep component-specific JS in component folders +- Extract reusable utilities to shared files (e.g., `table-utils.js`) +- Use function declarations for hoisted helpers +- Avoid duplicate function definitions + +### 6. Testing +- Write unit tests for domain logic +- Integration tests for application services +- Use ABP's test infrastructure + +## Common Operations + +### Adding a New Entity +1. Create entity in `*.Domain` project +2. Add to `DbContext` in `*.EntityFrameworkCore` +3. Create migration +4. Create DTOs in `*.Application.Contracts` +5. Create application service in `*.Application` +6. Add AutoMapper mappings +7. Define permissions +8. Create MVC controller and views + +### Database Migrations +```powershell +# From Unity.GrantManager.EntityFrameworkCore project directory +dotnet ef migrations add MigrationName +dotnet ef database update +``` + +### Adding Localization Keys +1. Add to `en.json` in `Localization/GrantManager` folder +2. Add translations for other supported languages +3. Use `L["KeyName"]` in code + +## Important Files & Utilities + +### JavaScript Utilities +- `table-utils.js`: DataTables initialization and helpers +- `DateUtils`: Date formatting utilities +- `createNumberFormatter()`: Currency formatting + +### Common JavaScript Patterns +```javascript +// Localization +const l = abp.localization.getResource('GrantManager'); + +// AJAX calls +abp.ajax({ + url: '/api/app/grant-application/...', + type: 'POST', + data: JSON.stringify(data) +}); + +// Notifications +abp.notify.success(l('SavedSuccessfully')); +abp.notify.error(l('ErrorOccurred')); +``` + +## ABP 9.1.3 Features for Unity Grant Manager + +### 1. Background Jobs for Long-Running Operations +**Use Cases**: Application processing, bulk operations, report generation, email notifications + +```csharp +// Define a background job +public class ProcessApplicationJob : AsyncBackgroundJob +{ + private readonly IGrantApplicationRepository _repository; + + public ProcessApplicationJob(IGrantApplicationRepository repository) + { + _repository = repository; + } + + public override async Task ExecuteAsync(ProcessApplicationArgs args) + { + var application = await _repository.GetAsync(args.ApplicationId); + // Process application logic + } +} + +// Enqueue a job +await _backgroundJobManager.EnqueueAsync(new ProcessApplicationArgs { ApplicationId = id }); +``` + +**Configuration** (in module class): +```csharp +Configure(options => +{ + options.IsJobExecutionEnabled = true; // Enable background job execution +}); +``` + +### 2. Blob Storage for Document Management +**Use Cases**: Storing application documents, attachments, generated reports + +```csharp +// Inject IBlobContainer +private readonly IBlobContainer _blobContainer; + +// Save a file +await _blobContainer.SaveAsync("document-name.pdf", stream, overrideExisting: true); + +// Retrieve a file +var stream = await _blobContainer.GetAsync("document-name.pdf"); + +// Delete a file +await _blobContainer.DeleteAsync("document-name.pdf"); +``` + +**Configuration** (module class): +```csharp +// Configure Blob Storage for different containers +Configure(options => +{ + options.Containers.Configure(container => + { + container.UseFileSystem(fileSystem => + { + fileSystem.BasePath = Path.Combine(hostingEnvironment.ContentRootPath, "Documents"); + }); + }); +}); +``` + +**Database Provider Alternative**: +```csharp +container.UseDatabase(); // Stores blobs in database +``` + +### 3. Global Feature System for Feature Toggles +**Use Cases**: Enable/disable features like assessment scoring, due diligence checks, payment processing + +```csharp +// Define features in Domain.Shared +public static class GrantManagerFeatures +{ + public const string AdvancedScoring = "GrantManager.AdvancedScoring"; + public const string AutomatedDueDiligence = "GrantManager.AutomatedDueDiligence"; + public const string PaymentIntegration = "GrantManager.PaymentIntegration"; +} + +// Configure in module +GlobalFeatureManager.Instance.Modules.GrantManager() + .EnableAll(); // Or .Enable(GrantManagerFeatures.AdvancedScoring) + +// Check feature in code +if (await FeatureChecker.IsEnabledAsync(GrantManagerFeatures.AdvancedScoring)) +{ + // Execute advanced scoring logic +} + +// In Razor views +@if (await FeatureChecker.IsEnabledAsync(GrantManagerFeatures.PaymentIntegration)) +{ + +} +``` + +### 4. Distributed Events for Workflow Management +**Use Cases**: Application state changes, notifications, audit trail, integration with external systems + +ABP 9.1.3 improves distributed event handling with better inbox/outbox pattern support. + +```csharp +// Define event (in Domain.Shared) +[Serializable] +public class ApplicationApprovedEto : EtoBase +{ + public Guid ApplicationId { get; set; } + public decimal ApprovedAmount { get; set; } +} + +// Publish event (in Application Service or Domain Entity) +await _distributedEventBus.PublishAsync(new ApplicationApprovedEto +{ + ApplicationId = id, + ApprovedAmount = amount +}); + +// Handle event (in Application layer) +public class ApplicationApprovedEventHandler : + IDistributedEventHandler, + ITransientDependency +{ + private readonly IEmailSender _emailSender; + + public ApplicationApprovedEventHandler(IEmailSender emailSender) + { + _emailSender = emailSender; + } + + public async Task HandleEventAsync(ApplicationApprovedEto eventData) + { + // Send approval email + // Create payment record + // Update external systems + } +} +``` + +**Configure Outbox for Reliability**: +```csharp +Configure(options => +{ + options.Outboxes.Configure(config => + { + config.UseDbContext(); + }); +}); +``` + +### 5. Enhanced Audit Logging +**Use Cases**: Track all changes to grant applications, compliance reporting, user activity monitoring + +ABP 9.1.3 provides better audit log filtering and querying. + +```csharp +// Disable auditing for specific method +[DisableAuditing] +public async Task GetLargeReportAsync() +{ + // Method not audited +} + +// Custom audit log properties +public class GrantApplicationAppService : ApplicationService +{ + public async Task ApproveAsync(Guid id, decimal amount) + { + // Add custom audit data + AuditingManager.Current.Log.EntityChanges.Add(new EntityChangeInfo + { + ChangeType = EntityChangeType.Updated, + EntityId = id.ToString(), + PropertyChanges = new List + { + new EntityPropertyChangeInfo + { + PropertyName = "ApprovalAmount", + NewValue = amount.ToString(), + OriginalValue = "0" + } + } + }); + } +} + +// Query audit logs (in a service) +var auditLogs = await _auditLogRepository.GetListAsync( + includeDetails: true, + httpMethod: "POST", + url: "/api/app/grant-application", + userName: "admin", + startTime: DateTime.UtcNow.AddDays(-7), + endTime: DateTime.UtcNow +); +``` + +### 6. Setting Management for Configurable Parameters +**Use Cases**: Approval thresholds, deadline configurations, scoring weights, notification preferences + +```csharp +// Define settings (in Domain.Shared) +public static class GrantManagerSettings +{ + public const string ApprovalThreshold = "GrantManager.ApprovalThreshold"; + public const string MaxApplicationsPerUser = "GrantManager.MaxApplicationsPerUser"; + public const string AutoCloseDeadlineDays = "GrantManager.AutoCloseDeadlineDays"; +} + +// Define setting definition provider +public class GrantManagerSettingDefinitionProvider : SettingDefinitionProvider +{ + public override void Define(ISettingDefinitionContext context) + { + context.Add( + new SettingDefinition( + GrantManagerSettings.ApprovalThreshold, + "100000", + isVisibleToClients: true, + isEncrypted: false + ), + new SettingDefinition( + GrantManagerSettings.MaxApplicationsPerUser, + "5", + isVisibleToClients: true + ) + ); + } +} + +// Use settings in code +var threshold = await SettingProvider.GetAsync(GrantManagerSettings.ApprovalThreshold); + +if (amount > threshold) +{ + // Require additional approval +} + +// Get setting in JavaScript +var maxApps = await abp.setting.get('GrantManager.MaxApplicationsPerUser'); +``` + +### 7. Dynamic Claims for Custom Authorization +**Use Cases**: Department-based access, region-based filtering, role-based data visibility + +```csharp +// Define custom claim type +public static class GrantManagerClaims +{ + public const string Department = "GrantManager_Department"; + public const string Region = "GrantManager_Region"; + public const string MaxApprovalAmount = "GrantManager_MaxApprovalAmount"; +} + +// Add dynamic claims (in Identity module) +public class GrantManagerClaimsPrincipalContributor : IAbpClaimsPrincipalContributor, ITransientDependency +{ + public async Task ContributeAsync(AbpClaimsPrincipalContributorContext context) + { + var identity = context.ClaimsPrincipal.Identities.FirstOrDefault(); + var userId = identity?.FindUserId(); + + if (userId.HasValue) + { + // Add custom claims from user profile or database + var userDepartment = await GetUserDepartmentAsync(userId.Value); + identity?.AddClaim(new Claim(GrantManagerClaims.Department, userDepartment)); + } + } +} + +// Use in authorization +[Authorize] +public async Task> GetMyDepartmentApplicationsAsync() +{ + var department = CurrentUser.FindClaimValue(GrantManagerClaims.Department); + return await _repository.GetListAsync(x => x.Department == department); +} +``` + +### 8. EF Core 8 Features (if using .NET 8+) +**New Capabilities**: JSON columns, raw SQL queries, complex type mapping + +```csharp +// JSON column mapping (for flexible metadata) +public class GrantApplication : FullAuditedAggregateRoot +{ + public string ReferenceNo { get; set; } + public ApplicationMetadata Metadata { get; set; } // Stored as JSON +} + +// In DbContext configuration +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity(b => + { + b.OwnsOne(e => e.Metadata, b => b.ToJson()); + }); +} + +// Raw SQL queries with better performance +var applications = await _dbContext.Database + .SqlQuery($"EXEC GetTopApplications @Year = {year}") + .ToListAsync(); +``` + +### 9. Object Extension System for Extensibility +**Use Cases**: Add custom fields without modifying core entities + +```csharp +// Configure in EntityFrameworkCore module +ObjectExtensionManager.Instance + .AddOrUpdateProperty( + "CustomField1", + options => { options.MapEfCore(b => b.HasMaxLength(128)); } + ); + +// Use in application service +application.SetProperty("CustomField1", "CustomValue"); +var value = application.GetProperty("CustomField1"); +``` + +### 10. Text Template Management +**Use Cases**: Email templates, document generation, notification templates + +```csharp +// Define template +public class ApprovalEmailTemplate : TemplateDefinitionProvider +{ + public override void Define(ITemplateDefinitionContext context) + { + context.Add( + new TemplateDefinition("ApprovalEmail") + .WithVirtualFilePath("/Templates/ApprovalEmail.tpl", isInlineLocalized: true) + ); + } +} + +// Use template +var emailBody = await _templateRenderer.RenderAsync( + "ApprovalEmail", + new { ApplicantName = "John Doe", Amount = 50000 } +); +``` + +## Module Structure +Unity Grant Manager includes: +- **Unity.Shared**: Shared components across Unity applications +- **MessageBrokers**: RabbitMQ integration (consider using ABP distributed events) +- **modules/**: Various ABP modules + +## Additional Resources +- ABP Framework Documentation: https://docs.abp.io +- ABP 9.1 Release Notes: https://docs.abp.io/en/abp/9.1/Release-Info +- Project README: `/Unity/applications/Unity.GrantManager/README.md` +- Architecture documentation: `/Unity/documentation/` + +## Recommended Next Steps for ABP 9.1.3 Integration + +1. **Implement Blob Storage** for document management (replace file system storage) +2. **Add Distributed Events** for application workflow state changes +3. **Configure Background Jobs** for report generation and notifications +4. **Use Setting Management** for configurable business rules (thresholds, deadlines) +5. **Leverage Global Features** for feature flags in production +6. **Enhance Audit Logging** for compliance requirements +7. **Implement Dynamic Claims** for department/region-based access control +8. **Use Text Templates** for standardized email and document generation + +--- + +**Remember**: This is an ABP Framework MVC application, NOT Angular. Use Razor views, jQuery, and traditional server-side rendering patterns. diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs index b04396a5f7..8b7ae33fc5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/DataGridExtensions.cs @@ -34,6 +34,31 @@ var ct when IsCheckBoxColumn(ct) => ValueConverterHelpers.ConvertCheckbox(value) }; } + public static string ApplyInputFormatting(this string value, string columnType, PresentationSettings presentationSettings) + { + if (value == null) return string.Empty; + + return columnType switch + { + var ct when IsDateColumn(ct) && TryParseDate(value, out string formatted) => formatted, + var ct when IsDateTimeColumn(ct) && TryParseDateTimeForInput(value, presentationSettings.BrowserOffsetMinutes, out string formatted) => formatted, + var ct when IsCurrencyColumn(ct) => ValueConverterHelpers.ConvertDecimal(value), + _ => value + }; + } + + private static bool TryParseDateTimeForInput(string value, int browserOffsetMinutes, out string formatted) + { + if (DateTimeOffset.TryParse(value, new CultureInfo("en-CA"), DateTimeStyles.None, out DateTimeOffset dateTimeOffset)) + { + dateTimeOffset = dateTimeOffset.ToOffset(TimeSpan.FromMinutes(-browserOffsetMinutes)); + formatted = dateTimeOffset.DateTime.ToString("yyyy-MM-ddTHH:mm", CultureInfo.InvariantCulture); + return true; + } + formatted = string.Empty; + return false; + } + private static bool TryParseDateTime(string value, int browserOffsetMinutes, out string formattedDateTime) { // Apply the browser offset before presenting the data diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json index 853d66df36..9f34865060 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Shared/Localization/Flex/en.json @@ -33,6 +33,7 @@ "Worksheet:Configuration:AddSelectListOptionText": "Add Option", "Worksheet:Configuration:AddColumnOptionText": "Add Column", "DataGrids:DynamicColumnsHeader": "Dynamic Columns", - "DataGrids:CustomColumnsHeader": "Custom Columns" + "DataGrids:CustomColumnsHeader": "Custom Columns", + "DataGrids:PredefinedColumn": "Predefined column" } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs index a08a9e2452..c215e80c1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridReadService.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; using Unity.Flex.WorksheetInstances; using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Definitions; using Unity.Flex.Worksheets.Values; using Volo.Abp; using Volo.Abp.Application.Services; @@ -42,7 +44,7 @@ internal static Dictionary ApplyPresentationFormat( return formattedKeyValuePairs; } - internal async Task<(KeyValuePair[] dynamicFields, List? customFields)> GetPropertiesAsync(RowInputData dataProps, PresentationSettings presentationSettings) + internal async Task<(DynamicFieldMap[] dynamicFields, List? customFields)> GetPropertiesAsync(RowInputData dataProps, PresentationSettings presentationSettings) { if (IsFirstRow(dataProps)) { @@ -79,7 +81,7 @@ private async Task> GetNewRowAsync(RowInputData da return DataGridServiceUtils.ExtractCustomColumnsValues(dataGridValue, datagridDefinition, dataProps.Row, true); } - private async Task<(KeyValuePair[] dynamicFields, List customFields)> GetExistingRowAsync(RowInputData dataProps, PresentationSettings presentationSettings) + private async Task<(DynamicFieldMap[] dynamicFields, List customFields)> GetExistingRowAsync(RowInputData dataProps, PresentationSettings presentationSettings) { if (dataProps.ValueId == null) throw new ArgumentNullException(nameof(dataProps)); var customFieldValue = await customFieldValueAppService.GetAsync(dataProps.ValueId.Value); @@ -90,7 +92,11 @@ private async Task> GetNewRowAsync(RowInputData da var dataGridValue = JsonSerializer.Deserialize(customFieldValue.CurrentValue ?? "{}"); - return (DataGridServiceUtils.ExtractDynamicColumnsPairs(dataGridValue, dataProps.Row, presentationSettings), + var definition = JsonSerializer.Deserialize(datagridDefinition.Definition ?? "{}"); + var customColumnKeys = new HashSet( + definition?.Columns.Select(c => c.Name) ?? [], StringComparer.Ordinal); + + return (DataGridServiceUtils.ExtractDynamicColumnsValues(dataGridValue, dataProps.Row, presentationSettings, customColumnKeys), DataGridServiceUtils.ExtractCustomColumnsValues(dataGridValue, datagridDefinition, dataProps.Row, false)); } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs index 8c8acbd6e1..402db923fc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridServiceUtils.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; using Unity.Flex.Worksheets; @@ -32,10 +33,12 @@ internal static List ExtractCustomColumnsValues(DataGri foreach (var column in definition.Columns) { + var displayName = value?.Columns?.Find(c => c.Key == column.Name)?.Name ?? column.Name; + fieldsToEdit.Add(new WorksheetFieldViewModel() { Name = column.Name, - Label = column.Name, + Label = displayName, Id = Guid.Empty, CurrentValue = GetCurrentValueAndTransform(dataRow, column), CurrentValueId = Guid.Empty, @@ -59,7 +62,18 @@ internal static string GetDefaultDefinition(string type) { if (dataRow == null) return null; var cell = dataRow.Cells.Find(s => s.Key == column.Name); - return ValueConverter.Convert(cell?.Value ?? string.Empty, CustomFieldType.Text); + var columnType = Enum.Parse(column.Type); + var rawValue = cell?.Value ?? string.Empty; + + // Normalize DateTime values to yyyy-MM-ddTHH:mm for HTML datetime-local input compatibility + if (columnType == CustomFieldType.DateTime + && !string.IsNullOrEmpty(rawValue) + && DateTime.TryParse(rawValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt)) + { + rawValue = dt.ToString("yyyy-MM-ddTHH:mm", CultureInfo.InvariantCulture); + } + + return ValueConverter.Convert(rawValue, columnType); } internal static CustomFieldType ResolveTypeColumnName(string key, DataGridDefinition? definition) @@ -105,6 +119,8 @@ private static string FormatValue(string value, CustomFieldType type) return type switch { CustomFieldType.Currency => ValueConverterHelpers.ConvertDecimal(value), + CustomFieldType.Date => ValueConverterHelpers.ConvertDate(value), + CustomFieldType.DateTime => ValueConverterHelpers.ConvertDateTime(value), CustomFieldType.YesNo => ValueConverterHelpers.ConvertYesNo(value), CustomFieldType.Checkbox => ValueConverterHelpers.ConvertCheckbox(value), _ => value @@ -125,11 +141,12 @@ internal static List SetRowCells(List[] ExtractDynamicColumnsPairs(DataGridValue? dataGridValue, + internal static DynamicFieldMap[] ExtractDynamicColumnsValues(DataGridValue? dataGridValue, uint rowNumber, - PresentationSettings presentationSettings) + PresentationSettings presentationSettings, + HashSet? excludeKeys = null) { - var keyValues = new List>(); + var keyValues = new List(); var gridValue = DeserializeDataGridValue(dataGridValue?.Value?.ToString()); if (gridValue == null) return []; var gridRowsValue = DeserializeDataGridRowsValue(dataGridValue?.Value?.ToString()); @@ -138,11 +155,21 @@ internal static KeyValuePair[] ExtractDynamicColumnsPairs(DataGr foreach (var column in dataGridValue?.Columns ?? []) { + if (excludeKeys != null && excludeKeys.Contains(column.Key)) + continue; + var cell = row.Cells.Find(s => s.Key == column.Key); if (cell != null) { - keyValues.Add(new(column.Name, cell.Value.ApplyPresentationFormatting(column.Type, null, presentationSettings))); + keyValues.Add(new DynamicFieldMap + { + Key = column.Key, + Name = column.Name, + Value = cell.Value.ApplyPresentationFormatting(column.Type, null, presentationSettings), + InputValue = cell.Value.ApplyInputFormatting(column.Type, presentationSettings), + Type = column.Type + }); } } @@ -173,4 +200,13 @@ public class WriteDataRowResponse public Guid WorksheetId { get; set; } public uint Row { get; set; } } + + public class DynamicFieldMap + { + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string InputValue { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml index 61770a1f6b..cec22d74f7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml @@ -1,6 +1,4 @@ @page -@using Microsoft.Extensions.Localization; -@using Unity.Flex.Localization; @using Unity.Flex.Web.Views.Shared.Components.CheckboxWidget; @using Unity.Flex.Web.Views.Shared.Components.CurrencyWidget; @using Unity.Flex.Web.Views.Shared.Components.DateWidget; @@ -8,11 +6,14 @@ @using Unity.Flex.Web.Views.Shared.Components.YesNoWidget @using Unity.Flex.Worksheets; @using Unity.Flex; +@using Unity.Flex.Localization; +@using Microsoft.Extensions.Localization; @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal; +@inject IStringLocalizer L + @model Unity.Flex.Web.Pages.Flex.EditDataRowModalModel; -@inject IStringLocalizer L @{ Layout = null; } @@ -31,29 +32,80 @@ -
- @if (Model.DynamicFields?.Length > 0) + +
+ @foreach (var entry in Model.AllFields) { -
-
@L["DataGrids:DynamicColumnsHeader"]:
- @foreach (var field in Model.DynamicFields ?? []) - { -
- - -
- } -
- } -
- @if (Model.DynamicFields?.Length > 0) + @if (entry.IsDynamic) { -
@L["DataGrids:CustomColumnsHeader"]:
+ var df = entry.DynamicField!; + var fieldType = Enum.TryParse(df.Type, out var parsed) ? parsed : CustomFieldType.Text; + var isEditable = fieldType is CustomFieldType.Text or CustomFieldType.TextArea + or CustomFieldType.Currency or CustomFieldType.Numeric + or CustomFieldType.Date or CustomFieldType.DateTime + or CustomFieldType.Checkbox or CustomFieldType.Phone or CustomFieldType.Email; +
+ @if (!isEditable) + { + + + } + else if (fieldType == CustomFieldType.Checkbox) + { +
+ @if (df.Value.IsTruthy()) + { + + } + else + { + + } + +
+ } + else if (fieldType == CustomFieldType.Currency) + { + +
+ $ + + +
+ } + else if (fieldType == CustomFieldType.TextArea) + { + + + } + else + { + + + } +
} - @foreach (var field in Model.Properties ?? []) + else { + var field = entry.CustomField!;
- + @try { @switch (field.Type) @@ -111,7 +163,7 @@
} -
+ }
@@ -167,11 +219,11 @@ function rowEditFormHasInvalidCurrencyCustomFields(formId) { let invalidFieldsFound = false; - $("#" + formId + " input:visible").each(function (i, el) { + $('#' + formId + ' input:visible').each(function (i, el) { let $field = $(this); if ($field.hasClass('custom-currency-input')) { if ($field.val() === '' || $field.val() === '0') { - $field.val('0.00'); + $field.val('0.00'); } if (!isValidCurrencyCustomField($field)) { invalidFieldsFound = true; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs index 6aed835b6a..50fc621fa5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/EditDataRowModal.cshtml.cs @@ -2,10 +2,13 @@ using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; -using System.Linq; +using Unity.Flex.Worksheets; using Unity.Modules.Shared.Utils; namespace Unity.Flex.Web.Pages.Flex; @@ -45,11 +48,18 @@ public class EditDataRowModalModel(DataGridWriteService dataGridWriteService, public List? Properties { get; set; } [BindProperty] - public KeyValuePair[]? DynamicFields { get; set; } + public DynamicFieldMap[]? DynamicFields { get; set; } [BindProperty] public string? CheckboxKeys { get; set; } + [BindProperty] + public string? DynamicKeyMap { get; set; } + + public List AllFields { get; private set; } = []; + + private const string DynamicFieldPrefix = "dynamicXdF-"; + public async Task OnGetAsync(Guid valueId, Guid fieldId, uint row, @@ -86,24 +96,55 @@ public async Task OnGetAsync(Guid valueId, PresentationSettings presentationSettings = new() { BrowserOffsetMinutes = browserUtils.GetBrowserOffset() }; var (dynamicFields, customFields) = await dataGridReadService.GetPropertiesAsync(dataProps, presentationSettings); Properties = customFields; - DynamicFields = dynamicFields ?? []; - CheckboxKeys = string.Join(',', Properties?.Where(s => s.Type == Worksheets.CustomFieldType.Checkbox).Select(s => s.Name) ?? []); + DynamicFields = PrefixDynamicFields(dynamicFields ?? []); + + var customCheckboxKeys = Properties?.Where(s => s.Type == CustomFieldType.Checkbox).Select(s => s.Name) ?? []; + var dynamicCheckboxKeys = DynamicFields?.Where(df => df.Type == CustomFieldType.Checkbox.ToString()).Select(df => df.Key[DynamicFieldPrefix.Length..]) ?? []; + CheckboxKeys = string.Join(',', customCheckboxKeys.Concat(dynamicCheckboxKeys)); + + var keyMap = DynamicFields?.ToDictionary( + df => df.Key[DynamicFieldPrefix.Length..], + df => new DynamicKeyMapEntry(df.Name, df.Type) + ) ?? new Dictionary(); + + foreach (var cf in Properties ?? []) + { + keyMap.TryAdd(cf.Name, new DynamicKeyMapEntry(cf.Label, cf.Type.ToString(), IsDynamic: false)); + } + + DynamicKeyMap = JsonSerializer.Serialize(keyMap); + + AllFields = MergeAndSortFields(DynamicFields ?? [], Properties ?? []); + } + + private static DynamicFieldMap[] PrefixDynamicFields(DynamicFieldMap[] dynamicFieldMaps) + { + foreach (var map in dynamicFieldMaps) + { + map.Key = $"{DynamicFieldPrefix}{map.Key}"; + } + + return dynamicFieldMaps; } public async Task OnPostAsync() { - var keyValuePairs = GetKeyValuePairs(Request.Form); + var keyValuePairs = StripDynamicFieldPrefix(GetKeyValuePairs(Request.Form)); var presentationSettings = new PresentationSettings() { BrowserOffsetMinutes = browserUtils.GetBrowserOffset() }; if (CheckboxKeys != null) { - var keysToCheck = CheckboxKeys.Split(','); - foreach (var key in keysToCheck) + foreach (var key in CheckboxKeys.Split(',')) { - keyValuePairs.TryAdd(key, "false"); + keyValuePairs[key] = keyValuePairs.TryGetValue(key, out var existing) && existing.IsTruthy() + ? "true" + : "false"; } } + var dynamicTypeMap = JsonSerializer.Deserialize>(DynamicKeyMap ?? "{}") ?? []; + ConvertDateTimeValuesForStorage(keyValuePairs, dynamicTypeMap, presentationSettings.BrowserOffsetMinutes); + var dataProps = new RowInputData() { FieldId = FieldId, @@ -119,6 +160,8 @@ public async Task OnPostAsync() }; var result = await dataGridWriteService.WriteRowAsync(dataProps); + var updates = DataGridReadService.ApplyPresentationFormat(keyValuePairs, result.MappedValues, presentationSettings); + ApplyDynamicFieldPresentationFormat(updates, dynamicTypeMap, presentationSettings); return new OkObjectResult(new ModalResponse() { @@ -128,10 +171,10 @@ public async Task OnPostAsync() WorksheetId = result.WorksheetId, Row = result.Row, IsNew = result.IsNew, - Updates = DataGridReadService.ApplyPresentationFormat(keyValuePairs, result.MappedValues, presentationSettings), + Updates = updates, UiAnchor = UiAnchor }); - } + } private static Dictionary GetKeyValuePairs(IFormCollection form) { @@ -147,9 +190,95 @@ where Regex.IsMatch(key, pattern, RegexOptions.None, TimeSpan.FromSeconds(30)) keyValuePairs[prefix] = value.ToString(); } + foreach (string key in form.Keys.Where(key => + key.StartsWith(DynamicFieldPrefix, StringComparison.Ordinal) && !keyValuePairs.ContainsKey(key))) + { + keyValuePairs[key] = form[key].ToString(); + } + return keyValuePairs; } + private static Dictionary StripDynamicFieldPrefix(Dictionary keyValuePairs) + { + var result = new Dictionary(); + foreach (var kvp in keyValuePairs) + { + var key = kvp.Key.StartsWith(DynamicFieldPrefix, StringComparison.Ordinal) + ? kvp.Key[DynamicFieldPrefix.Length..] + : kvp.Key; + result[key] = kvp.Value; + } + return result; + } + + private static void ConvertDateTimeValuesForStorage( + Dictionary keyValuePairs, + Dictionary dynamicTypeMap, + int browserOffsetMinutes) + { + var browserOffset = TimeSpan.FromMinutes(-browserOffsetMinutes); + var dateTimeType = CustomFieldType.DateTime.ToString(); + + foreach (var (key, _) in dynamicTypeMap.Where(e => e.Value.IsDynamic && e.Value.Type == dateTimeType)) + { + if (keyValuePairs.TryGetValue(key, out var rawValue) + && !string.IsNullOrEmpty(rawValue) + && DateTime.TryParse(rawValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var localDateTime)) + { + var dto = new DateTimeOffset(localDateTime, browserOffset); + keyValuePairs[key] = dto.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture); + } + } + } + + private static void ApplyDynamicFieldPresentationFormat( + Dictionary updates, + Dictionary dynamicTypeMap, + PresentationSettings presentationSettings) + { + foreach (var (key, entry) in dynamicTypeMap.Where(e => + e.Value.IsDynamic && updates.ContainsKey(e.Key) && !string.IsNullOrEmpty(updates[e.Key]))) + { + updates[key] = updates[key].ApplyPresentationFormatting(entry.Type, null, presentationSettings); + } + } + + private sealed record DynamicKeyMapEntry(string Name, string Type, bool IsDynamic = true); + + private static List MergeAndSortFields(DynamicFieldMap[] dynamicFields, List customFields) + { + var fields = new List(); + + foreach (var df in dynamicFields) + { + fields.Add(new EditRowField + { + SortKey = df.Name, + DynamicField = df + }); + } + + foreach (var cf in customFields) + { + fields.Add(new EditRowField + { + SortKey = cf.Label, + CustomField = cf + }); + } + + return [.. fields.OrderBy(f => f.SortKey, StringComparer.OrdinalIgnoreCase)]; + } + + public class EditRowField + { + public string SortKey { get; set; } = string.Empty; + public WorksheetFieldViewModel? CustomField { get; set; } + public DynamicFieldMap? DynamicField { get; set; } + public bool IsDynamic => DynamicField != null; + } + public class ModalResponse { public uint Row { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs index 6f7b01918d..50b64b5e13 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridViewModel.cs @@ -8,6 +8,7 @@ public class DataGridViewModel : WorksheetViewModelBase { public bool AllowEdit { get; set; } public string[] Columns { get; set; } = []; + public string[] ColumnKeys { get; set; } = []; public string TableOptions { get; set; } = string.Empty; public DataGridViewModelRow[] Rows { get; set; } = []; public DataGridDefinitionSummaryOption SummaryOption { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs index 9c90de0d29..c020c735c7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidget.cs @@ -106,12 +106,14 @@ private IViewComponentResult GenerateGridView(WorksheetFieldViewModel fieldModel var dataColumns = GenerateDataColumns(dataGridValue, dataGridDefinition); var dataRows = GenerateDataRows(dataColumns, dataGridRowsValue, presentationSetttings); var columnNames = dataColumns.Select(s => s.Name); + var columnKeys = dataColumns.Select(s => s.Key); var viewModel = new DataGridViewModel() { Field = fieldModel, Name = modelName, Columns = [.. columnNames], + ColumnKeys = [.. columnKeys], Rows = [.. dataRows], AllowEdit = true, SummaryOption = ConvertSummaryOption(dataGridDefinition), @@ -140,7 +142,9 @@ private static List GenerateDataColumns(DataGridValue? dataGridV { // Predefined column definitions List dataColumns = dataGridValue?.Columns ?? []; - foreach (var dataColumn in dataGridDefinition?.Columns ?? []) + var existingKeys = new HashSet(dataColumns.Select(c => c.Key), StringComparer.Ordinal); + + foreach (var dataColumn in (dataGridDefinition?.Columns ?? []).Where(c => !existingKeys.Contains(c.Name))) { dataColumns.Add(new DataGridColumn() { @@ -225,6 +229,7 @@ private IViewComponentResult GenerateNonDynamicWithColumnsPreview(WorksheetField return View(new DataGridViewModel() { Columns = [.. columnsToRender], + ColumnKeys = [.. columnsToRender], Summary = summary, Rows = [.. previewRows], SummaryOption = ConvertSummaryOption(dataGridDefinition), @@ -247,6 +252,7 @@ private IViewComponentResult GenerateDynamicWithColumnsPreview(WorksheetFieldVie return View(new DataGridViewModel() { Columns = [.. columnsToRender], + ColumnKeys = [.. columnsToRender], Summary = summary, Rows = [.. previewRows], SummaryOption = ConvertSummaryOption(dataGridDefinition), @@ -261,11 +267,12 @@ private IViewComponentResult GenerateDynamicWithNoColumnsPreview(WorksheetFieldV return View(new DataGridViewModel() { Columns = [.. GenerateDynamicPlaceholderColumn()], + ColumnKeys = [.. GenerateDynamicPlaceholderColumn()], Rows = [.. GenerateDynamicRowPlaceholder()], Summary = GenerateDynamicPlaceholderSummary(), SummaryOption = ConvertSummaryOption(dataGridDefinition), Field = fieldModel, - AllowEdit = false, + AllowEdit = true, TableOptions = GenerateAvailableTableOptions(false) }); } @@ -382,23 +389,19 @@ private static DataGridViewSummary GenerateSummary(DataGridColumn[]? dataColumns private static string SumCells(string? key, DataGridViewModelRow[] rows) { decimal sum = 0; - foreach (var row in rows) + foreach (var cell in rows.Select(row => row.Cells.Find(x => x.Key == key)).Where(cell => cell != null)) { - var cell = row.Cells.Find(x => x.Key == key); - if (cell != null) + var preparse = cell!.Value.Replace("$", "").Replace(",", ""); + if (decimal.TryParse(preparse, out decimal value)) { - var preparse = cell.Value.Replace("$", "").Replace(",", ""); - if (decimal.TryParse(preparse, out decimal value)) + if (decimal.MaxValue - sum >= value) { - if (decimal.MaxValue - sum >= value) - { - sum += value; - } - else - { - sum = decimal.MaxValue; - break; - } + sum += value; + } + else + { + sum = decimal.MaxValue; + break; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml index bb81408961..9fe91322c3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.cshtml @@ -28,7 +28,7 @@
- + - @foreach (var column in Model.Columns) + @for (int i = 0; i < Model.Columns.Length; i++) { - + } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css index 60cd6e7752..9d075a3c6a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.css @@ -42,21 +42,14 @@ padding-bottom: 1rem; } -.custom-fields-split { +.edit-row-fields { display: flex; - flex-direction: row; - justify-content: space-around; - align-items: flex-start; -} - -.custom-fields-split > div { - width: 100%; -} - -.customgrid-edit-row-header { - margin-bottom: 1rem; + flex-direction: column; } -.customgrid-edit-dymanic-field { - width: 90%; +.static-field-indicator { + font-size: 0.7em; + color: #8a8886; + margin-left: 4px; + vertical-align: middle; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js index 69d90ae048..0fc4c6c6b7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/Default.js @@ -84,8 +84,8 @@ $(function () { if (isNewRow) { // Convert dataToUpdate object to an array of values in the same order as the columns let newRowData = table.columns().header().toArray().map(header => { - let columnName = $(header).text(); - return dataToUpdate[columnName] !== undefined ? dataToUpdate[columnName] : ''; + let key = $(header).data('key'); + return dataToUpdate[key] === undefined ? '' : dataToUpdate[key]; }); // Add a placeholder for the button in the last column @@ -152,11 +152,11 @@ $(function () { return !isNaN(value) && isFinite(value); } - // Function to get the index of a column by its name - function getColumnIndex(table, columnName) { + // Function to get the index of a column by its key + function getColumnIndex(table, key) { let headers = table.columns().header().toArray(); for (let i = 0; i < headers.length; i++) { - if ($(headers[i]).text() === columnName) { + if ($(headers[i]).data('key') === key) { return i; } } return -1; // Return -1 if the column is not found diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailLogAttachmentDto.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailLogAttachmentDto.cs new file mode 100644 index 0000000000..fcccb3545d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/EmailLogAttachmentDto.cs @@ -0,0 +1,16 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Unity.Notifications.Emails; + +[Serializable] +public class EmailLogAttachmentDto : EntityDto +{ + public string? FileName { get; set; } + public string? DisplayName { get; set; } + public DateTime Time { get; set; } + public long FileSize { get; set; } + public string ContentType { get; set; } = string.Empty; + public string S3ObjectKey { get; set; } = string.Empty; + public string AttachedBy { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentAppService.cs new file mode 100644 index 0000000000..bb9377063e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentAppService.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; + +namespace Unity.Notifications.Emails; + +public interface IEmailLogAttachmentAppService : IApplicationService +{ + Task> GetListByEmailLogIdAsync(Guid emailLogId); + Task DeleteAsync(Guid id); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs new file mode 100644 index 0000000000..23f2ae4f03 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Emails/IEmailLogAttachmentUploadService.cs @@ -0,0 +1,9 @@ +using System; +using System.Threading.Tasks; + +namespace Unity.Notifications.Emails; + +public interface IEmailLogAttachmentUploadService +{ + Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs index eb031554d0..77a905aada 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationManager.cs @@ -86,8 +86,36 @@ public class EmailNotificationManager( } } + public async Task CreateDraftEmailLogAsync(Guid applicationId) + { + var emailLog = new EmailLog + { + ApplicationId = applicationId, + Status = EmailStatus.Draft + }; + return await emailLogsRepository.InsertAsync(emailLog, autoSave: true); + } + public async Task DeleteEmailLogAsync(Guid id) { + var emailLog = await emailLogsRepository.GetAsync(id); + if (emailLog.Status == EmailStatus.Sent) + { + throw new UserFriendlyException("Sent emails cannot be deleted."); + } + + var attachments = await emailAttachmentService.GetAttachmentsAsync(id); + foreach (var s3Key in attachments.Select(attachment => attachment.S3ObjectKey)) + { + try + { + await emailAttachmentService.DeleteFromS3Async(s3Key); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete S3 attachment for EmailLog {EmailLogId}", id); + } + } await emailLogsRepository.DeleteAsync(id); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index ffeb49a214..87881499f4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -31,6 +31,12 @@ public class EmailNotificationService( IFeatureChecker featureChecker) : ApplicationService, IEmailNotificationService { + public async Task InitializeDraftAsync(Guid applicationId) + { + var emailLog = await emailNotificationManager.CreateDraftEmailLogAsync(applicationId); + return emailLog.Id; + } + public async Task DeleteEmail(Guid id) { await emailNotificationManager.DeleteEmailLogAsync(id); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs index 19885e38e9..cc9fe5a31f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationManager.cs @@ -32,7 +32,12 @@ public interface IEmailNotificationManager Task GetEmailLogByIdAsync(Guid id); /// - /// Deletes an email log + /// Creates an empty draft email log for composing + /// + Task CreateDraftEmailLogAsync(Guid applicationId); + + /// + /// Deletes an email log and its S3 attachments /// Task DeleteEmailLogAsync(Guid id); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs index b009f53fb1..c598fef948 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/IEmailNotificationService.cs @@ -20,6 +20,7 @@ public interface IEmailNotificationService : IApplicationService Task SendEmailToQueue(EmailLog emailLog); Task> GetHistoryByApplicationId(Guid applicationId); Task UpdateSettings(NotificationsSettingsDto settingsDto); + Task InitializeDraftAsync(Guid applicationId); Task DeleteEmail(Guid id); Task GetEmailsChesWithNoResponseCountAsync(); } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs index 35068660d2..71ca3e02be 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs @@ -14,6 +14,8 @@ namespace Unity.Notifications.EmailNotifications; public class EmailAttachmentService : ITransientDependency { + private const string S3BucketConfigKey = "S3:Bucket"; + private readonly AmazonS3Client _amazonS3Client; private readonly IEmailLogAttachmentRepository _emailLogAttachmentRepository; private readonly IConfiguration _configuration; @@ -55,7 +57,7 @@ public async Task UploadAttachmentAsync( string contentType) { var s3Key = BuildS3Key(tenantId, emailLogId, fileName); - var bucket = _configuration["S3:Bucket"]; + var bucket = _configuration[S3BucketConfigKey]; // Upload to S3 using var uploadStream = new MemoryStream(fileContent); @@ -94,7 +96,7 @@ public async Task UploadAttachmentAsync( public async Task DownloadFromS3Async(string s3ObjectKey) { - var bucket = _configuration["S3:Bucket"]; + var bucket = _configuration[S3BucketConfigKey]; var getObjectRequest = new GetObjectRequest { @@ -111,11 +113,76 @@ public async Task UploadAttachmentAsync( return memoryStream.ToArray(); } + public async Task UploadUserAttachmentAsync( + Guid emailLogId, + Guid? tenantId, + string fileName, + byte[] fileContent, + string contentType) + { + var uniqueKey = Guid.NewGuid(); + var s3Key = BuildUserAttachmentS3Key(tenantId, emailLogId, uniqueKey, fileName); + var bucket = _configuration[S3BucketConfigKey]; + + using var uploadStream = new MemoryStream(fileContent); + var putRequest = new PutObjectRequest + { + BucketName = bucket, + Key = s3Key, + ContentType = contentType, + InputStream = uploadStream, + UseChunkEncoding = false, + DisablePayloadSigning = false + }; + + await _amazonS3Client.PutObjectAsync(putRequest); + _logger.LogInformation( + "Uploaded user email attachment to S3: FileName={FileName}, FileSize={FileSize}", + fileName, fileContent.Length); + + var attachment = new EmailLogAttachment + { + EmailLogId = emailLogId, + S3ObjectKey = s3Key, + FileName = fileName, + DisplayName = fileName, + ContentType = contentType, + FileSize = fileContent.Length, + Time = DateTime.UtcNow, + UserId = _currentUser.Id ?? Guid.Empty, + TenantId = tenantId + }; + + await _emailLogAttachmentRepository.InsertAsync(attachment); + return attachment; + } + + public async Task DeleteFromS3Async(string s3ObjectKey) + { + var bucket = _configuration[S3BucketConfigKey]; + var deleteRequest = new DeleteObjectRequest + { + BucketName = bucket, + Key = s3ObjectKey + }; + await _amazonS3Client.DeleteObjectAsync(deleteRequest); + _logger.LogInformation("Deleted email attachment from S3."); + } + public async Task> GetAttachmentsAsync(Guid emailLogId) { return await _emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); } + private static string BuildUserAttachmentS3Key(Guid? tenantId, Guid emailLogId, Guid attachmentId, string fileName) + { + var basePath = "Email/Attachments"; + var tenantPart = tenantId?.ToString() ?? "host"; + var escapedFileName = Uri.EscapeDataString(fileName); + + return $"{basePath}/{tenantPart}/{emailLogId}/{attachmentId}/{escapedFileName}"; + } + private static string BuildS3Key(Guid? tenantId, Guid emailLogId, string fileName) { var basePath = "Email/FSB-AP-Payments"; @@ -124,6 +191,4 @@ private static string BuildS3Key(Guid? tenantId, Guid emailLogId, string fileNam return $"{basePath}/{tenantPart}/{emailLogId}/{escapedFileName}"; } - - } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs new file mode 100644 index 0000000000..b7e1c4d75b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.Notifications.EmailNotifications; +using Unity.Notifications.Permissions; +using Volo.Abp; +using Volo.Abp.Application.Services; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Users; + +namespace Unity.Notifications.Emails; + +[Authorize(NotificationsPermissions.Email.Send)] +[ExposeServices(typeof(EmailLogAttachmentAppService), typeof(IEmailLogAttachmentAppService), typeof(IEmailLogAttachmentUploadService))] +public class EmailLogAttachmentAppService( + IEmailLogAttachmentRepository emailLogAttachmentRepository, + IEmailLogsRepository emailLogsRepository, + EmailAttachmentService emailAttachmentService, + IExternalUserLookupServiceProvider externalUserLookupServiceProvider) : ApplicationService, IEmailLogAttachmentAppService, IEmailLogAttachmentUploadService +{ + public async Task> GetListByEmailLogIdAsync(Guid emailLogId) + { + var attachments = await emailLogAttachmentRepository.GetByEmailLogIdAsync(emailLogId); + var dtos = new List(); + + foreach (var attachment in attachments) + { + var dto = new EmailLogAttachmentDto + { + Id = attachment.Id, + FileName = attachment.FileName, + DisplayName = attachment.DisplayName, + Time = attachment.Time, + FileSize = attachment.FileSize, + ContentType = attachment.ContentType, + S3ObjectKey = attachment.S3ObjectKey, + AttachedBy = await ResolveUserNameAsync(attachment.UserId) + }; + dtos.Add(dto); + } + + return dtos; + } + + public async Task DeleteAsync(Guid id) + { + var attachment = await emailLogAttachmentRepository.GetAsync(id); + + var emailLog = await emailLogsRepository.GetAsync(attachment.EmailLogId); + if (emailLog.Status != EmailStatus.Draft) + { + throw new UserFriendlyException("Attachments can only be deleted from draft emails."); + } + + try + { + await emailAttachmentService.DeleteFromS3Async(attachment.S3ObjectKey); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to delete S3 object {S3ObjectKey} for attachment {AttachmentId}", attachment.S3ObjectKey, id); + } + await emailLogAttachmentRepository.DeleteAsync(id); + } + + public async Task UploadAsync(Guid emailLogId, Guid? tenantId, string fileName, byte[] content, string contentType) + { + var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, tenantId, fileName, content, contentType); + + return new EmailLogAttachmentDto + { + Id = attachment.Id, + FileName = attachment.FileName, + DisplayName = attachment.DisplayName, + Time = attachment.Time, + FileSize = attachment.FileSize, + ContentType = attachment.ContentType, + S3ObjectKey = attachment.S3ObjectKey, + AttachedBy = await ResolveUserNameAsync(attachment.UserId) + }; + } + + private async Task ResolveUserNameAsync(Guid userId) + { + try + { + var user = await externalUserLookupServiceProvider.FindByIdAsync(userId); + if (user == null) return string.Empty; + + var fullName = $"{user.Name} {user.Surname}".Trim(); + return string.IsNullOrEmpty(fullName) ? user.UserName : fullName; + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to resolve username for UserId {UserId}", userId); + return string.Empty; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs index b2d4ee5872..d51c68030e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/InvoiceConsumer.cs @@ -1,23 +1,29 @@ +using System; using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; -using Unity.Payments.RabbitMQ.QueueMessages; -using System; -using Volo.Abp.MultiTenancy; using Unity.Payments.Integrations.Cas; +using Unity.Payments.RabbitMQ.QueueMessages; namespace Unity.Payments.Integrations.RabbitMQ; -public class InvoiceConsumer(InvoiceService invoiceService, - ICurrentTenant currentTenant) : IQueueConsumer +/// +/// Processes invoice creation messages from RabbitMQ. +/// Tenant context and audit scope are established by +/// before this consumer is invoked — no manual wiring needed here. +/// +public class InvoiceConsumer( + InvoiceService invoiceService +) : IQueueConsumer { public async Task ConsumeAsync(InvoiceMessages invoiceMessage) { - if (invoiceMessage != null && !invoiceMessage.InvoiceNumber.IsNullOrEmpty() && invoiceMessage.TenantId != Guid.Empty) + if (invoiceMessage == null || + invoiceMessage.InvoiceNumber.IsNullOrEmpty() || + invoiceMessage.TenantId == Guid.Empty) { - using (currentTenant.Change(invoiceMessage.TenantId)) - { - await invoiceService.CreateInvoiceByPaymentRequestAsync(invoiceMessage.InvoiceNumber); - } + return; } + + await invoiceService.CreateInvoiceByPaymentRequestAsync(invoiceMessage.InvoiceNumber); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs index 48ac9fb986..78b7824bee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/InvoiceMessages.cs @@ -3,7 +3,7 @@ namespace Unity.Payments.RabbitMQ.QueueMessages { - public class InvoiceMessages : IQueueMessage + public class InvoiceMessages : ITenantedQueueMessage { public Guid MessageId { get; set; } public TimeSpan TimeToLive { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs index 7b7737d682..5798351f58 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/QueueMessages/ReconcilePaymentMessages.cs @@ -3,7 +3,7 @@ namespace Unity.Payments.RabbitMQ.QueueMessages { - public class ReconcilePaymentMessages : IQueueMessage + public class ReconcilePaymentMessages : ITenantedQueueMessage { public Guid MessageId { get; set; } public TimeSpan TimeToLive { get; set; } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs index c462ae9b35..1fc39278ba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs @@ -1,40 +1,46 @@ +using System; using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; -using Unity.Payments.RabbitMQ.QueueMessages; -using System; -using Unity.Payments.PaymentRequests; using Unity.Payments.Integrations.Cas; -using Volo.Abp.MultiTenancy; +using Unity.Payments.PaymentRequests; +using Unity.Payments.RabbitMQ.QueueMessages; namespace Unity.Payments.Integrations.RabbitMQ; +/// +/// Processes payment reconciliation messages from RabbitMQ. +/// Tenant context and audit scope are established by +/// before this consumer is invoked — no manual wiring needed here. +/// public class ReconciliationConsumer( - CasPaymentRequestCoordinator casPaymentRequestCoordinator, - InvoiceService invoiceService, - ICurrentTenant currentTenant - ) : IQueueConsumer + CasPaymentRequestCoordinator casPaymentRequestCoordinator, + InvoiceService invoiceService +) : IQueueConsumer { public async Task ConsumeAsync(ReconcilePaymentMessages reconcilePaymentMessage) { - if (reconcilePaymentMessage != null && !reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() && reconcilePaymentMessage.TenantId != Guid.Empty) - { + if (reconcilePaymentMessage == null || + reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() || + reconcilePaymentMessage.TenantId == Guid.Empty) + { + return; + } - using (currentTenant.Change(reconcilePaymentMessage.TenantId)) - { - // string invoiceNumber, string supplierNumber, string siteNumber) - // Go to CAS retrieve the status of the payment - CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( - reconcilePaymentMessage.TenantId, - reconcilePaymentMessage.InvoiceNumber, - reconcilePaymentMessage.SupplierNumber, - reconcilePaymentMessage.SiteNumber); + // string invoiceNumber, string supplierNumber, string siteNumber) + // Go to CAS retrieve the status of the payment + CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync( + reconcilePaymentMessage.TenantId, + reconcilePaymentMessage.InvoiceNumber, + reconcilePaymentMessage.SupplierNumber, + reconcilePaymentMessage.SiteNumber); - if (result != null && result.InvoiceStatus != null && result.InvoiceStatus != "") - { - await casPaymentRequestCoordinator.UpdatePaymentRequestStatus(reconcilePaymentMessage.TenantId, reconcilePaymentMessage.PaymentRequestId, result); - } - } + + if (!string.IsNullOrEmpty(result?.InvoiceStatus)) + { + await casPaymentRequestCoordinator.UpdatePaymentRequestStatus( + reconcilePaymentMessage.TenantId, + reconcilePaymentMessage.PaymentRequestId, + result); } } - } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs index 7e2e287bec..a4698477af 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs @@ -15,28 +15,15 @@ namespace Unity.Payments.PaymentRequests { - public class CasPaymentRequestCoordinator : ApplicationService - { - private readonly IPaymentRequestRepository _paymentRequestsRepository; - private readonly IUnitOfWorkManager _unitOfWorkManager; - private readonly ITenantRepository _tenantRepository; - private readonly ICurrentTenant _currentTenant; - private readonly PaymentQueueService _paymentQueueService; - private static int TenMinutes = 10; - - public CasPaymentRequestCoordinator( - PaymentQueueService paymentQueueService, + public class CasPaymentRequestCoordinator(PaymentQueueService paymentQueueService, IPaymentRequestRepository paymentRequestsRepository, IUnitOfWorkManager unitOfWorkManager, ITenantRepository tenantRepository, - ICurrentTenant currentTenant) - { - _paymentQueueService = paymentQueueService; - _paymentRequestsRepository = paymentRequestsRepository; - _tenantRepository = tenantRepository; - _currentTenant = currentTenant; - _unitOfWorkManager = unitOfWorkManager; - } + ICurrentTenant currentTenant) : ApplicationService + { + + private static int TenMinutes = 10; + protected virtual dynamic GetPaymentRequestObject( Guid paymentRequestId, @@ -60,7 +47,7 @@ public async Task AddPaymentRequestsToInvoiceQueue(PaymentRequest paymentRequest { try { - if (!string.IsNullOrEmpty(paymentRequest.InvoiceNumber) && _currentTenant != null && _currentTenant.Id != null) + if (!string.IsNullOrEmpty(paymentRequest.InvoiceNumber) && currentTenant != null && currentTenant.Id != null) { InvoiceMessages message = new InvoiceMessages { @@ -69,10 +56,10 @@ public async Task AddPaymentRequestsToInvoiceQueue(PaymentRequest paymentRequest InvoiceNumber = paymentRequest.InvoiceNumber, SupplierNumber = paymentRequest.SupplierNumber, SiteNumber = paymentRequest.Site.Number, - TenantId = (Guid)_currentTenant.Id + TenantId = (Guid)currentTenant.Id }; - await _paymentQueueService.SendPaymentToInvoiceQueueAsync(message); + await paymentQueueService.SendPaymentToInvoiceQueueAsync(message); } } catch (Exception ex) @@ -93,21 +80,21 @@ public async Task ManuallyAddPaymentRequestsToReconciliationQueue(List tenant.Id)) { - using (_currentTenant.Change(tenantId)) + using (currentTenant.Change(tenantId)) { - List paymentRequests = await _paymentRequestsRepository.GetPaymentRequestsBySentToCasStatusAsync(); + List paymentRequests = await paymentRequestsRepository.GetPaymentRequestsBySentToCasStatusAsync(); foreach (PaymentRequest paymentRequest in paymentRequests) { ReconcilePaymentMessages reconcilePaymentMessage = new ReconcilePaymentMessages @@ -120,52 +107,60 @@ public async Task AddPaymentRequestsToReconciliationQueue() TenantId = tenantId }; - await _paymentQueueService.SendPaymentToReconciliationQueueAsync(reconcilePaymentMessage); + await paymentQueueService.SendPaymentToReconciliationQueueAsync(reconcilePaymentMessage); } } } } + /// + /// Updates payment request status from CAS integration results. + /// Tenant context and audit scope are already established by the caller + /// (via ); + /// this method only needs to own its unit of work. + /// public async Task UpdatePaymentRequestStatus(Guid TenantId, Guid PaymentRequestId, CasPaymentSearchResult result) { - PaymentRequest? paymentReqeust = null; - if (TenantId != Guid.Empty) + if (TenantId == Guid.Empty) { - using (_currentTenant.Change(TenantId)) - { - try - { - using var uow = _unitOfWorkManager.Begin(true, false); - paymentReqeust = await _paymentRequestsRepository.GetAsync(PaymentRequestId); - if (paymentReqeust != null) - { - if(paymentReqeust.InvoiceStatus == CasPaymentRequestStatus.NotFound && result.InvoiceStatus == CasPaymentRequestStatus.NotFound) - { - result.InvoiceStatus = CasPaymentRequestStatus.NotFound+"2"; - } - - paymentReqeust.SetInvoiceStatus(result.InvoiceStatus ?? ""); - paymentReqeust.SetPaymentStatus(result.PaymentStatus ?? ""); - paymentReqeust.SetPaymentDate(result.PaymentDate ?? ""); - paymentReqeust.SetPaymentNumber(result.PaymentNumber ?? ""); - if(result.InvoiceStatus != null) - { - paymentReqeust.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); - paymentReqeust.SetCasResponse("SUCCEEDED"); - } - - await _paymentRequestsRepository.UpdateAsync(paymentReqeust, autoSave: false); - await uow.SaveChangesAsync(); - } - } - catch (Exception ex) - { - string ExceptionMessage = ex.Message; - Logger.LogInformation(ex, "UpdatePaymentRequestStatus: Error updating payment request: {ExceptionMessage}", ExceptionMessage); - } - } + return null; + } + + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true); + + var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId); + + UpdatePaymentRequestFromCasResult(paymentRequest, result); + + await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false); + + // CompleteAsync commits the transaction and calls SaveChangesAsync, + // which triggers AbpDbContext to collect entity changes into the active audit log. + // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns. + await uow.CompleteAsync(); + + return paymentRequest; + } + + private static void UpdatePaymentRequestFromCasResult(PaymentRequest paymentRequest, CasPaymentSearchResult result) + { + // Handle duplicate NotFound status by appending "2" + if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound && + result.InvoiceStatus == CasPaymentRequestStatus.NotFound) + { + result.InvoiceStatus = CasPaymentRequestStatus.NotFound + "2"; + } + + paymentRequest.SetInvoiceStatus(result.InvoiceStatus ?? ""); + paymentRequest.SetPaymentStatus(result.PaymentStatus ?? ""); + paymentRequest.SetPaymentDate(result.PaymentDate ?? ""); + paymentRequest.SetPaymentNumber(result.PaymentNumber ?? ""); + + if (result.InvoiceStatus != null) + { + paymentRequest.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK); + paymentRequest.SetCasResponse("SUCCEEDED"); } - return paymentReqeust; } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs index 46a506fb26..db352a16aa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentsApplicationModule.cs @@ -15,10 +15,12 @@ using Volo.Abp.Application.Dtos; using Volo.Abp.AspNetCore.ExceptionHandling; using Unity.Payments.PaymentRequests.Notifications; +using Unity.Modules.Shared.Auditing; namespace Unity.Payments; [DependsOn( + typeof(UnityAuditingOverrideModule), typeof(AbpVirtualFileSystemModule), typeof(AbpDddApplicationModule), typeof(AbpAutoMapperModule), diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs new file mode 100644 index 0000000000..eafd1acb3c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/BackgroundJobAuditPropertySetter.cs @@ -0,0 +1,58 @@ +using Unity.Modules.Shared.Constants; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Timing; +using Volo.Abp.Users; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// Custom audit property setter that ensures background jobs have proper user context. +/// With proper BackgroundJobContext setup, ABP should populate most values automatically. +/// This provides a safety net fallback using reflection for readonly properties. +/// +public class BackgroundJobAuditPropertySetter : AuditPropertySetter, ITransientDependency +{ + public BackgroundJobAuditPropertySetter(ICurrentUser currentUser, ICurrentTenant currentTenant, IClock clock) + : base(currentUser, currentTenant, clock) + { + } + + public override void SetCreationProperties(object targetObject) + { + // Call base first to let ABP try to set properties + base.SetCreationProperties(targetObject); + + // If in background job context and ABP hasn't set creator, use background job user + if (BackgroundJobExecutionContext.IsActive && + targetObject is ICreationAuditedObject createdObject && + createdObject.CreatorId == null) + { + var propertyInfo = targetObject.GetType().GetProperty(nameof(ICreationAuditedObject.CreatorId)); + if (propertyInfo != null && propertyInfo.CanWrite) + { + propertyInfo.SetValue(targetObject, BackgroundJobConstants.BackgroundJobPersonId); + } + } + } + + public override void SetModificationProperties(object targetObject) + { + // Call base first to let ABP try to set properties + base.SetModificationProperties(targetObject); + + // If in background job context and ABP hasn't set modifier, use background job user + if (BackgroundJobExecutionContext.IsActive && + targetObject is IModificationAuditedObject modifiedObject && + modifiedObject.LastModifierId == null) + { + var propertyInfo = targetObject.GetType().GetProperty(nameof(IModificationAuditedObject.LastModifierId)); + if (propertyInfo != null && propertyInfo.CanWrite) + { + propertyInfo.SetValue(targetObject, BackgroundJobConstants.BackgroundJobPersonId); + } + } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs new file mode 100644 index 0000000000..821b06ccc2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingHelper.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Unity.Modules.Shared.Constants; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.DependencyInjection; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// Custom auditing helper that forces audit logging for background job operations. +/// Wraps ABP's default AuditingHelper to intercept auditing decisions and ensure +/// EntityChanges are recorded even when no authenticated user is present. +/// +public class UnityAuditingHelper : IAuditingHelper, ITransientDependency +{ + private readonly AuditingHelper _inner; + + public UnityAuditingHelper(AuditingHelper inner) + { + _inner = inner; + } + + public bool ShouldSaveAudit(MethodInfo? methodInfo, bool defaultValue = false, bool ignoreIntegrationServiceAttribute = false) + { + // Force auditing for background jobs - bypass normal checks that fail when currentUser.Id is null + if (BackgroundJobExecutionContext.IsActive) + { + return true; + } + + return _inner.ShouldSaveAudit(methodInfo, defaultValue, ignoreIntegrationServiceAttribute); + } + + public bool IsEntityHistoryEnabled(Type entityType, bool defaultValue = false) + { + // Force entity history for background jobs - ensures EntityChanges table gets populated + if (BackgroundJobExecutionContext.IsActive) + { + return true; + } + + return _inner.IsEntityHistoryEnabled(entityType, defaultValue); + } + + public AuditLogInfo CreateAuditLogInfo() + { + var auditLogInfo = _inner.CreateAuditLogInfo(); + + // Enrich audit log with background job user when no authenticated user present + if (BackgroundJobExecutionContext.IsActive && auditLogInfo.UserId == null) + { + auditLogInfo.UserId = BackgroundJobConstants.BackgroundJobPersonId; + auditLogInfo.UserName = BackgroundJobConstants.BackgroundJobUserName; + } + + return auditLogInfo; + } + + public AuditLogActionInfo CreateAuditLogAction( + AuditLogInfo auditLog, + Type? type, + MethodInfo method, + object?[] arguments) + { + return _inner.CreateAuditLogAction(auditLog, type, method, arguments); + } + + public AuditLogActionInfo CreateAuditLogAction( + AuditLogInfo auditLog, + Type? type, + MethodInfo method, + IDictionary arguments) + { + return _inner.CreateAuditLogAction(auditLog, type, method, arguments); + } +} + diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs new file mode 100644 index 0000000000..0cd68880c4 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Auditing/UnityAuditingOverrideModule.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Volo.Abp.Auditing; +using Volo.Abp.Modularity; + +namespace Unity.Modules.Shared.Auditing; + +/// +/// ABP module that overrides default auditing behavior to support background job entity change tracking. +/// Registers custom implementations that force auditing when BackgroundJobExecutionContext is active. +/// +[DependsOn( + typeof(AbpAuditingModule) +)] +public class UnityAuditingOverrideModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + // Override audit property setter to handle readonly audit properties in background jobs + context.Services.Replace( + ServiceDescriptor.Transient() + ); + + // Override auditing helper to force entity change tracking for background jobs + context.Services.Replace( + ServiceDescriptor.Transient() + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs new file mode 100644 index 0000000000..43a106ad90 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/BackgroundJobConstants.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.Modules.Shared.Constants; + +public static class BackgroundJobConstants +{ + // Well-known fixed GUID for the Background Job Execution Person record (one per tenant) + public static readonly Guid BackgroundJobPersonId = new("00000000-0000-0000-0000-000000000002"); + public const string BackgroundJobOidcSub = "unity-background-job"; + public const string BackgroundJobDisplayName = "Unity Background Job Execution"; + public const string BackgroundJobBadge = "BGJ"; + public const string BackgroundJobUserName = "UBGJ"; + public const string BackgroundJobName = "UnityBackgroundJob"; + public const string BackgroundJobEmail = "grantmanagementsupport@gov.bc.ca"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs new file mode 100644 index 0000000000..4aac91736e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/ITenantedQueueMessage.cs @@ -0,0 +1,15 @@ +using System; + +namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces +{ + /// + /// Extends for messages that carry tenant context. + /// Implementing this interface causes + /// to automatically establish background-job auditing scope before invoking the consumer, + /// mirroring the way ASP.NET Core middleware wraps controller actions. + /// + public interface ITenantedQueueMessage : IQueueMessage + { + Guid TenantId { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index c94fc005af..d64d0a1248 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -8,6 +8,10 @@ using RabbitMQ.Client.Events; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Exceptions; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; +using Unity.Modules.Shared.Utils; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ { @@ -86,8 +90,21 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) _logger.LogInformation("Processing MessageId {MessageId}", message.MessageId); - var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); - await consumerInstance.ConsumeAsync(message); + if (message is not ITenantedQueueMessage tenantedMessage) + { + var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); + await consumerInstance.ConsumeAsync(message); + } + else if (tenantedMessage.TenantId == Guid.Empty) + { + _logger.LogError("Message {MessageId} on {Queue} has an empty TenantId and cannot be processed", message.MessageId, _queueName); + consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + return; + } + else + { + await ConsumeWithAuditingAsync(consumerScope, tenantedMessage, message); + } consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); @@ -105,6 +122,58 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) } } + /// + /// Wraps consumer execution in a background-job auditing scope, mirroring the way + /// ASP.NET Core middleware wraps controller actions. Tenant context, identity, and + /// audit persistence are handled here so individual consumers stay free of + /// infrastructure concerns. + /// + private static async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, ITenantedQueueMessage tenantedMessage, TQueueMessage message) + { + var auditingManager = consumerScope.ServiceProvider.GetRequiredService(); + var principalAccessor = consumerScope.ServiceProvider.GetRequiredService(); + var currentTenant = consumerScope.ServiceProvider.GetRequiredService(); + var auditingStore = consumerScope.ServiceProvider.GetRequiredService(); + + using (BackgroundJobExecutionContext.Use()) + using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantedMessage.TenantId)) + { + AddConsumerAuditAction(auditingManager, message); + + var consumerInstance = consumerScope.ServiceProvider.GetRequiredService(); + await consumerInstance.ConsumeAsync(message); + + // Persist audit log if the consumer produced any entity changes. + // Entity changes are collected by AbpDbContext.SaveChangesAsync() during UOW commit, + // so this call happens after the consumer's unit of work completes. + if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log) + { + await auditingStore.SaveAsync(log); + } + } + } + + /// + /// Adds a single to the current audit scope using + /// reflection on the generic consumer type. ABP requires at least one recorded action + /// before it will persist an audit log with entity changes. + /// + private static void AddConsumerAuditAction(IAuditingManager auditingManager, TQueueMessage message) + { + if (auditingManager.Current?.Log == null) + { + return; + } + + auditingManager.Current.Log.Actions.Add(new AuditLogActionInfo + { + ServiceName = typeof(TMessageConsumer).FullName ?? typeof(TMessageConsumer).Name, + MethodName = nameof(IQueueConsumer.ConsumeAsync), + Parameters = System.Text.Json.JsonSerializer.Serialize(message), + ExecutionTime = DateTime.UtcNow + }); + } + private static TQueueMessage? DeserializeMessage(byte[] body) { var json = Encoding.UTF8.GetString(body); diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs new file mode 100644 index 0000000000..78ce8c6609 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs @@ -0,0 +1,96 @@ +using System; +using System.Security.Claims; +using Unity.Modules.Shared.Constants; +using Volo.Abp.Auditing; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Claims; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Utility for establishing proper execution context for background jobs and message consumers. +/// Sets up tenant, user identity, and audit scope required for entity change tracking. +/// +public static class BackgroundJobContext +{ + /// + /// Sets up complete background job execution context with auditing, tenant, and user identity. + /// CRITICAL: Must be called AFTER BackgroundJobExecutionContext.Use() to enable forced auditing. + /// + /// The auditing manager for creating audit scope + /// The current principal accessor for setting user identity + /// The current tenant accessor for setting tenant context + /// The tenant ID to set context for + /// Optional user ID. If null, uses BackgroundJobConstants.BackgroundJobPersonId + /// IDisposable that restores previous context when disposed (LIFO order) + public static IDisposable Set( + IAuditingManager auditingManager, + ICurrentPrincipalAccessor principalAccessor, + ICurrentTenant currentTenant, + Guid? tenantId, + Guid? userId = null) + { + var effectiveUserId = userId ?? BackgroundJobConstants.BackgroundJobPersonId; + + var claims = new[] + { + new Claim(AbpClaimTypes.UserId, effectiveUserId.ToString()), + new Claim(ClaimTypes.NameIdentifier, effectiveUserId.ToString()), // Standard claim for user ID + new Claim(AbpClaimTypes.UserName, BackgroundJobConstants.BackgroundJobUserName), + new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail), + new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()), + new Claim(AbpClaimTypes.Name, BackgroundJobConstants.BackgroundJobName) + }; + + // Create an authenticated identity (authenticationType must be non-null for IsAuthenticated to be true) + var identity = new ClaimsIdentity(claims, "BackgroundJob", AbpClaimTypes.UserName, AbpClaimTypes.Role); + var principal = new ClaimsPrincipal(identity); + + // CRITICAL: Set tenant and principal BEFORE starting audit scope + // This ensures ABP captures correct context when audit scope is created + var tenantDisposable = currentTenant.Change(tenantId); + var principalDisposable = principalAccessor.Change(principal); + + // NOW start auditing - it will see the correct tenant/user context + var auditingDisposable = auditingManager.BeginScope(); + + // Ensure the current audit log has the user ID set for entity change tracking + if (auditingManager.Current != null) + { + auditingManager.Current.Log.UserId = effectiveUserId; + auditingManager.Current.Log.UserName = BackgroundJobConstants.BackgroundJobUserName; + auditingManager.Current.Log.TenantId = tenantId; + } + + // Dispose in LIFO order: last registered is disposed first. + // audit scope closes first (while tenant/user context is still valid), + // then principal, then tenant. + return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable); + } + + /// + /// Private helper class to combine multiple disposables into one + /// + private sealed class CompositeDisposable : IDisposable + { + private readonly IDisposable[] _disposables; + private bool _disposed; + + public CompositeDisposable(params IDisposable[] disposables) + { + _disposables = disposables; + } + + public void Dispose() + { + if (!_disposed) + { + for (int i = _disposables.Length - 1; i >= 0; i--) + { + _disposables[i]?.Dispose(); + } + _disposed = true; + } + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs new file mode 100644 index 0000000000..ceb635bbb0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobExecutionContext.cs @@ -0,0 +1,50 @@ +using System; +using System.Threading; + +namespace Unity.Modules.Shared.Utils; + +/// +/// Context marker for background job execution that survives async boundaries. +/// Used by auditing overrides to detect when code is running in a background job. +/// +public static class BackgroundJobExecutionContext +{ + private static readonly AsyncLocal _isActive = new(); + + /// + /// Returns true if currently executing within a background job context. + /// + public static bool IsActive => _isActive.Value; + + /// + /// Marks the current async context as executing within a background job. + /// Returns an IDisposable that clears the marker when disposed. + /// + /// IDisposable to clear the background job context + public static IDisposable Use() + { + bool previous = _isActive.Value; + _isActive.Value = true; + return new DisposeAction(() => _isActive.Value = previous); + } + + private sealed class DisposeAction : IDisposable + { + private readonly Action _action; + private bool _disposed; + + public DisposeAction(Action action) + { + _action = action; + } + + public void Dispose() + { + if (!_disposed) + { + _action?.Invoke(); + _disposed = true; + } + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs index fc1fac75f3..5c60ea2ae0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/AI/Responses/AIPromptCaptureResponse.cs @@ -23,11 +23,8 @@ public class AIPromptCaptureResponse [JsonPropertyName("userPrompt")] public string UserPrompt { get; set; } = string.Empty; - [JsonPropertyName("rawOutput")] - public string RawOutput { get; set; } = string.Empty; - - [JsonPropertyName("formattedOutput")] - public string FormattedOutput { get; set; } = string.Empty; + [JsonPropertyName("output")] + public string Output { get; set; } = string.Empty; [JsonPropertyName("capturedAt")] public DateTime CapturedAt { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs index dd5f22e79a..97eafb6f4d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Applicants/ApplicantListDto.cs @@ -31,5 +31,5 @@ public class ApplicantListDto : AuditedEntityDto public string? SupplierId { get; set; } public Guid? SiteId { get; set; } public decimal? MatchPercentage { get; set; } - public bool? IsDuplicated { get; set; } + public bool IsDuplicated { get; set; } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs index 330c2551fb..c8d35e6141 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/ApplicantSummaryDto.cs @@ -22,4 +22,5 @@ public class ApplicantSummaryDto public string? FiscalDay { get; set; } public string? FiscalMonth { get; set; } public string? ElectoralDistrict { get; set; } + public bool IsDuplicated { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs index 22434446ff..9a4381db84 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/GrantApplications/GrantApplicationLiteDto.cs @@ -8,5 +8,6 @@ public class GrantApplicationLiteDto : AuditedEntityDto public string ProjectName { get; set; } = string.Empty; public string ReferenceNo { get; set; } = string.Empty; public string ApplicantName { get; set; } = string.Empty; - + public string OrganizationName { get; set; } = string.Empty; + public string UnityApplicantId { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs index 0c6b77a823..37ef649a82 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application.Contracts/Notifications/IEmailAppService.cs @@ -1,9 +1,11 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; namespace Unity.GrantManager.Emails { public interface IEmailAppService { Task CreateAsync(CreateEmailDto dto); + Task InitializeDraftAsync(Guid applicationId); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIOperationResult.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIOperationResult.cs new file mode 100644 index 0000000000..f1ffbda307 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIOperationResult.cs @@ -0,0 +1,33 @@ +namespace Unity.GrantManager.AI +{ + internal enum AIOperationOutcome + { + Success, + TransientFailure, + PermanentFailure, + InvalidOutput + } + + internal sealed record AIOperationResult( + AIOperationOutcome Outcome, + AIProviderResponse Response) + { + public string Content => Response.Content; + + public string CaptureOutput => Response.CaptureOutput; + + public static AIOperationResult Success(AIProviderResponse? response = null) => + new(AIOperationOutcome.Success, response ?? AIProviderResponse.Empty); + + public static AIOperationResult TransientFailure(AIProviderResponse? response = null) => + new(AIOperationOutcome.TransientFailure, response ?? AIProviderResponse.Empty); + + public static AIOperationResult PermanentFailure(AIProviderResponse? response = null) => + new(AIOperationOutcome.PermanentFailure, response ?? AIProviderResponse.Empty); + + public static AIOperationResult InvalidOutput(AIProviderResponse? response = null) => + new(AIOperationOutcome.InvalidOutput, response ?? AIProviderResponse.Empty); + + public AIOperationResult WithOutcome(AIOperationOutcome outcome) => new(outcome, Response); + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptTypes.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptTypes.cs new file mode 100644 index 0000000000..41ce17e33e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIPromptTypes.cs @@ -0,0 +1,8 @@ +namespace Unity.GrantManager.AI; + +public static class AIPromptTypes +{ + public const string AttachmentSummary = "AttachmentSummary"; + public const string ApplicationAnalysis = "ApplicationAnalysis"; + public const string ScoresheetSection = "ScoresheetSection"; +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponse.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponse.cs new file mode 100644 index 0000000000..973af81507 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponse.cs @@ -0,0 +1,17 @@ +namespace Unity.GrantManager.AI +{ + internal sealed record AIProviderResponse( + string Content, + string RawResponse = "", + string? Model = null, + string? FinishReason = null, + int? PromptTokens = null, + int? CompletionTokens = null, + int? TotalTokens = null, + int? ReasoningTokens = null) + { + public static AIProviderResponse Empty { get; } = new(string.Empty); + + public string CaptureOutput => string.IsNullOrWhiteSpace(RawResponse) ? Content : RawResponse; + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponseMetadata.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponseMetadata.cs new file mode 100644 index 0000000000..34e75b7d0e --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIProviderResponseMetadata.cs @@ -0,0 +1,10 @@ +namespace Unity.GrantManager.AI +{ + internal sealed record AIProviderResponseMetadata( + string? Model, + string? FinishReason, + int? PromptTokens, + int? CompletionTokens, + int? TotalTokens, + int? ReasoningTokens); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseJson.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseJson.cs new file mode 100644 index 0000000000..13c591f0f2 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseJson.cs @@ -0,0 +1,63 @@ +using System; + +namespace Unity.GrantManager.AI +{ + internal static class AIResponseJson + { + public 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[(startIndex + 1)..]; + } + else + { + 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/AIResponseValidator.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseValidator.cs new file mode 100644 index 0000000000..7d80341dfb --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/AIResponseValidator.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace Unity.GrantManager.AI +{ + internal static class AIResponseValidator + { + public static bool IsValidAttachmentSummaryText(string response) + { + return !string.IsNullOrWhiteSpace(response); + } + + public static bool IsValidApplicationAnalysisJson(string response) + { + if (!TryParseRootObject(response, out var root)) + { + return false; + } + + return root.TryGetProperty(AIJsonKeys.Rating, out var rating) + && rating.ValueKind == JsonValueKind.String + && root.TryGetProperty(AIJsonKeys.Errors, out var errors) + && errors.ValueKind == JsonValueKind.Array + && root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) + && warnings.ValueKind == JsonValueKind.Array + && root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) + && summaries.ValueKind == JsonValueKind.Array + && root.TryGetProperty(AIJsonKeys.NextSteps, out var nextSteps) + && nextSteps.ValueKind == JsonValueKind.Array; + } + + public static bool IsValidScoresheetSectionJson(string response, string sectionJson) + { + if (!TryParseRootObject(response, out var root)) + { + return false; + } + + var expectedQuestionIds = ExtractQuestionIds(sectionJson); + 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 answerValue) + || answerValue.ValueKind == JsonValueKind.Null + || answerValue.ValueKind == JsonValueKind.Object + || answerValue.ValueKind == JsonValueKind.Array) + { + return false; + } + + if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) + || confidenceValue.ValueKind != JsonValueKind.Number + || !confidenceValue.TryGetInt32(out var confidence) + || confidence < 0 + || confidence > 100) + { + return false; + } + } + + return true; + } + + private static HashSet ExtractQuestionIds(string sectionJson) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + using var jsonDoc = JsonDocument.Parse(sectionJson); + var root = jsonDoc.RootElement; + + if (root.ValueKind == JsonValueKind.Array) + { + AddQuestionIds(root, ids); + return ids; + } + + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("questions", out var questionsElement) && + questionsElement.ValueKind == JsonValueKind.Array) + { + AddQuestionIds(questionsElement, ids); + } + } + catch + { + return ids; + } + + return ids; + } + + private static void AddQuestionIds(JsonElement questionsArray, HashSet ids) + { + foreach (var item in questionsArray.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object || + !item.TryGetProperty("id", out var idProperty) || + idProperty.ValueKind != JsonValueKind.String) + { + continue; + } + + var id = idProperty.GetString(); + if (!string.IsNullOrWhiteSpace(id)) + { + ids.Add(id); + } + } + } + + private static bool TryParseRootObject(string response, out JsonElement root) + { + root = default; + + if (string.IsNullOrWhiteSpace(response)) + { + return false; + } + + try + { + using var jsonDoc = JsonDocument.Parse(AIResponseJson.CleanJsonResponse(response)); + if (jsonDoc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + root = jsonDoc.RootElement.Clone(); + return true; + } + catch + { + return false; + } + } + + } +} 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 bdc4d248e5..1ffa2cdee2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/OpenAIService.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Text.Json; @@ -20,9 +21,9 @@ public class OpenAIService : IAIService, ITransientDependency private readonly ILogger _logger; private readonly ITextExtractionService _textExtractionService; private readonly IAIPromptCaptureStore _promptIoCaptureStore; - private const string ApplicationAnalysisPromptType = "ApplicationAnalysis"; - private const string AttachmentSummaryPromptType = "AttachmentSummary"; - private const string ScoresheetSectionPromptType = "ScoresheetSection"; + private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; + private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; + private const string ScoresheetSectionPromptType = AIPromptTypes.ScoresheetSection; private const string PromptVersionV0 = "v0"; private const string PromptVersionV1 = "v1"; private static readonly string PromptTemplatesFolder = Path.Combine("AI", "Prompts", "Versions"); @@ -32,13 +33,21 @@ public class OpenAIService : IAIService, ITransientDependency private const string AttachmentUserTemplateName = "attachment.user"; private const string ScoresheetSystemTemplateName = "scoresheet.system"; private const string ScoresheetUserTemplateName = "scoresheet.user"; - 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 const int MaxAiAttempts = 3; + private const string DefaultMaxTokensParameterName = "max_completion_tokens"; + private const string LegacyMaxTokensParameterName = "max_tokens"; + private const string DefaultProviderName = "OpenAI"; + private const int DefaultCompletionTokens = 150; + private const int DefaultAttachmentSummaryCompletionTokens = 500; + private const int DefaultApplicationAnalysisCompletionTokens = 2500; + private const int DefaultScoresheetSectionCompletionTokens = 5000; + + private int AttachmentSummaryCompletionTokens => ResolveCompletionTokens("AttachmentSummary", DefaultAttachmentSummaryCompletionTokens); + private int ApplicationAnalysisCompletionTokens => ResolveCompletionTokens("ApplicationAnalysis", DefaultApplicationAnalysisCompletionTokens); + private int ScoresheetSectionCompletionTokens => ResolveCompletionTokens("ScoresheetSection", DefaultScoresheetSectionCompletionTokens); private readonly string MissingApiKeyMessage = "OpenAI API key is not configured"; // Optional local debugging sink for prompt payload logs to a local file. @@ -57,8 +66,6 @@ public class OpenAIService : IAIService, ITransientDependency }; private static readonly ConcurrentDictionary PromptTemplateCache = new(StringComparer.OrdinalIgnoreCase); - private string SelectedPromptVersion => ResolvePromptVersion(_configuration["Azure:OpenAI:PromptVersion"]); - public OpenAIService( HttpClient httpClient, IConfiguration configuration, @@ -75,7 +82,7 @@ public OpenAIService( public Task IsAvailableAsync() { - if (string.IsNullOrEmpty(ApiKey)) + if (string.IsNullOrEmpty(ResolveApiKey())) { _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); return Task.FromResult(false); @@ -86,18 +93,21 @@ public Task IsAvailableAsync() public async Task GenerateCompletionAsync(AICompletionRequest request) { - var content = await GenerateSummaryAsync( + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( request?.UserPrompt ?? string.Empty, null, - request?.MaxTokens ?? 150, - request?.Temperature); - return new AICompletionResponse { Content = content }; + request?.MaxTokens ?? DefaultCompletionTokens, + request?.Temperature), + AIResponseValidator.IsValidAttachmentSummaryText, + "completion"); + return new AICompletionResponse { Content = ResolveNarrativeContent(result) }; } public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request) { ArgumentNullException.ThrowIfNull(request); - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ApplicationAnalysisPromptType)); var capturePromptIo = request.CapturePromptIo; var data = JsonSerializer.Serialize(request.Data, JsonLogOptions); var schema = JsonSerializer.Serialize(request.Schema, JsonLogOptions); @@ -118,22 +128,44 @@ public async Task GenerateApplicationAnalysisAsync( data, attachments); await LogPromptInputAsync(ApplicationAnalysisPromptType, promptVersion, systemPrompt, analysisContent); - var raw = await GenerateSummaryAsync(analysisContent, systemPrompt, 1000); - await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, raw); - SavePromptCapture(capturePromptIo, request.CaptureContextId, ApplicationAnalysisPromptType, promptVersion, "Application Analysis", systemPrompt, analysisContent, raw); - return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(raw)); + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( + analysisContent, + systemPrompt, + ApplicationAnalysisCompletionTokens, + operationName: ApplicationAnalysisPromptType), + AIResponseValidator.IsValidApplicationAnalysisJson, + "application analysis"); + await LogPromptOutputAsync(ApplicationAnalysisPromptType, promptVersion, result.CaptureOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ApplicationAnalysisPromptType, promptVersion, "Application Analysis", systemPrompt, analysisContent, result.CaptureOutput); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new ApplicationAnalysisResponse(); + } + + return ParseApplicationAnalysisResponse(AddIdsToAnalysisItems(result.Content)); } - private async Task GenerateSummaryAsync( + private async Task GenerateSummaryAsync( string content, string? systemPrompt, int maxTokens = 150, - double? temperature = null) + double? temperature = null, + string? operationName = null) { - if (string.IsNullOrEmpty(ApiKey)) + var providerName = ResolveProviderName(operationName); + if (!string.Equals(providerName, DefaultProviderName, StringComparison.Ordinal)) + { + _logger.LogWarning("Provider {ProviderName} is not supported by OpenAIService.", providerName); + return AIOperationResult.PermanentFailure(new AIProviderResponse($"Unsupported provider: {providerName}")); + } + + var apiKey = ResolveApiKey(operationName); + if (string.IsNullOrEmpty(apiKey)) { _logger.LogWarning("Error: {Message}", MissingApiKeyMessage); - return ServiceNotConfiguredMessage; + return AIOperationResult.PermanentFailure(new AIProviderResponse(MissingApiKeyMessage)); } _logger.LogDebug("Calling OpenAI chat completions. PromptLength: {PromptLength}, MaxTokens: {MaxTokens}", content?.Length ?? 0, maxTokens); @@ -151,50 +183,74 @@ private async Task GenerateSummaryAsync( { new { role = "system", content = resolvedSystemPrompt }, new { role = "user", content = userPrompt } - }, - max_tokens = maxTokens, - temperature = temperature ?? 0.3 + } }; - var json = JsonSerializer.Serialize(requestBody); + var requestPayload = new Dictionary + { + ["messages"] = requestBody.messages, + [ResolveMaxTokensParameterNameForOperation(operationName)] = maxTokens + }; + + var resolvedTemperature = temperature ?? ResolveConfiguredTemperature(operationName); + if (resolvedTemperature.HasValue) + { + requestPayload["temperature"] = resolvedTemperature.Value; + } + + var json = JsonSerializer.Serialize(requestPayload); var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("Authorization", ApiKey); + _httpClient.DefaultRequestHeaders.Add("Authorization", apiKey); - var response = await _httpClient.PostAsync(ApiUrl, httpContent); + var response = await _httpClient.PostAsync(ResolveApiUrl(operationName), httpContent); var responseContent = await response.Content.ReadAsStringAsync(); + var metadata = TryExtractProviderMetadata(responseContent); + var providerResponse = BuildProviderResponseFromMetadata(string.Empty, responseContent, metadata); _logger.LogDebug( "OpenAI chat completions response received. StatusCode: {StatusCode}, ResponseLength: {ResponseLength}", response.StatusCode, responseContent?.Length ?? 0); + LogProviderMetadata(operationName, providerResponse); if (!response.IsSuccessStatusCode) { _logger.LogError("OpenAI API request failed: {StatusCode} - {Content}", response.StatusCode, responseContent); - return ServiceTemporarilyUnavailableMessage; + return MapFailureOutcome(response.StatusCode, providerResponse); } if (string.IsNullOrWhiteSpace(responseContent)) { - return NoSummaryGeneratedMessage; + return AIOperationResult.InvalidOutput(providerResponse); } - using var jsonDoc = JsonDocument.Parse(responseContent); - var choices = jsonDoc.RootElement.GetProperty("choices"); - if (choices.GetArrayLength() > 0) + try { - var message = choices[0].GetProperty("message"); - return message.GetProperty("content").GetString() ?? NoSummaryGeneratedMessage; - } + using var jsonDoc = JsonDocument.Parse(responseContent); + var choices = jsonDoc.RootElement.GetProperty("choices"); + if (choices.GetArrayLength() > 0) + { + var message = choices[0].GetProperty("message"); + var modelOutput = message.GetProperty("content").GetString(); + return string.IsNullOrWhiteSpace(modelOutput) + ? AIOperationResult.InvalidOutput(providerResponse) + : AIOperationResult.Success(BuildProviderResponseFromMetadata(modelOutput, responseContent, metadata)); + } - return NoSummaryGeneratedMessage; + return AIOperationResult.InvalidOutput(providerResponse); + } + catch (Exception ex) when (ex is JsonException || ex is KeyNotFoundException || ex is InvalidOperationException) + { + _logger.LogWarning(ex, "AI response payload had an invalid output shape"); + return AIOperationResult.InvalidOutput(providerResponse); + } } catch (Exception ex) { _logger.LogError(ex, "Error generating AI summary"); - return SummaryFailedRetryMessage; + return AIOperationResult.TransientFailure(new AIProviderResponse(ex.Message)); } } @@ -204,7 +260,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta var fileName = request.FileName ?? string.Empty; var fileContent = request.FileContent ?? Array.Empty(); var contentType = request.ContentType ?? "application/octet-stream"; - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(AttachmentSummaryPromptType)); var capturePromptIo = request.CapturePromptIo; try @@ -233,13 +289,28 @@ public async Task GenerateAttachmentSummaryAsync(Atta var contentToAnalyze = BuildAttachmentUserPrompt(promptVersion, attachment); await LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze); - var modelOutput = await GenerateSummaryAsync(contentToAnalyze, prompt, 150); - await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, modelOutput); - SavePromptCapture(capturePromptIo, request.CaptureContextId, AttachmentSummaryPromptType, promptVersion, fileName, prompt, contentToAnalyze, modelOutput); + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( + contentToAnalyze, + prompt, + AttachmentSummaryCompletionTokens, + operationName: AttachmentSummaryPromptType), + AIResponseValidator.IsValidAttachmentSummaryText, + "attachment summary"); + await LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, AttachmentSummaryPromptType, promptVersion, fileName, prompt, contentToAnalyze, result.CaptureOutput); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new AttachmentSummaryResponse + { + Summary = $"AI analysis not available for this attachment ({fileName})." + }; + } return new AttachmentSummaryResponse { - Summary = ExtractSummaryFromJson(modelOutput) + Summary = ExtractSummaryFromJson(result.Content) }; } catch (Exception ex) @@ -326,7 +397,7 @@ private string AddIdsToAnalysisItems(string analysisJson) public async Task GenerateScoresheetSectionAsync(ScoresheetSectionRequest request) { ArgumentNullException.ThrowIfNull(request); - var promptVersion = ResolvePromptVersion(request.PromptVersion ?? SelectedPromptVersion); + var promptVersion = ResolvePromptVersion(request.PromptVersion ?? ResolvePromptVersionSetting(ScoresheetSectionPromptType)); var capturePromptIo = request.CapturePromptIo; var dataJson = JsonSerializer.Serialize(request.Data, JsonLogOptions); var sectionJson = JsonSerializer.Serialize(request.SectionSchema, JsonLogOptions); @@ -334,8 +405,7 @@ public async Task GenerateScoresheetSectionAsync(Scor var attachmentSummaries = request.Attachments .Select(a => $"{a.Name}: {a.Summary}") .ToList(); - - if (string.IsNullOrEmpty(ApiKey)) + if (string.IsNullOrEmpty(ResolveApiKey(ScoresheetSectionPromptType))) { _logger.LogWarning("{Message}", MissingApiKeyMessage); return new ScoresheetSectionResponse(); @@ -385,11 +455,23 @@ public async Task GenerateScoresheetSectionAsync(Scor var systemPrompt = BuildScoresheetSectionSystemPrompt(promptVersion); await LogPromptInputAsync(ScoresheetSectionPromptType, promptVersion, systemPrompt, analysisContent); - var modelOutput = await GenerateSummaryAsync(analysisContent, systemPrompt, 2000); - await LogPromptOutputAsync(ScoresheetSectionPromptType, promptVersion, modelOutput); - SavePromptCapture(capturePromptIo, request.CaptureContextId, ScoresheetSectionPromptType, promptVersion, request.SectionName, systemPrompt, analysisContent, modelOutput); + var result = await GenerateWithRetryAsync( + () => GenerateSummaryAsync( + analysisContent, + systemPrompt, + ScoresheetSectionCompletionTokens, + operationName: ScoresheetSectionPromptType), + content => AIResponseValidator.IsValidScoresheetSectionJson(content, sectionJson), + $"scoresheet section {request.SectionName}"); + await LogPromptOutputAsync(ScoresheetSectionPromptType, promptVersion, result.CaptureOutput); + SavePromptCapture(capturePromptIo, request.CaptureContextId, ScoresheetSectionPromptType, promptVersion, request.SectionName, systemPrompt, analysisContent, result.CaptureOutput); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new ScoresheetSectionResponse(); + } - return ParseScoresheetSectionResponse(modelOutput); + return ParseScoresheetSectionResponse(result.Content); } catch (Exception ex) { @@ -398,6 +480,310 @@ public async Task GenerateScoresheetSectionAsync(Scor } } + private async Task GenerateWithRetryAsync( + Func> operation, + Func validator, + string operationName) + { + var lastResult = AIOperationResult.InvalidOutput(); + + for (var attempt = 1; attempt <= MaxAiAttempts; attempt++) + { + lastResult = await operation(); + + if (lastResult.Outcome == AIOperationOutcome.Success && validator(lastResult.Content)) + { + return lastResult; + } + + if (lastResult.Outcome == AIOperationOutcome.Success) + { + lastResult = lastResult.WithOutcome(AIOperationOutcome.InvalidOutput); + } + + if (lastResult.Outcome == AIOperationOutcome.PermanentFailure) + { + return lastResult; + } + + if (attempt < MaxAiAttempts) + { + if (lastResult.Outcome == AIOperationOutcome.TransientFailure) + { + _logger.LogWarning( + "AI {OperationName} attempt {Attempt}/{MaxAttempts} failed transiently; retrying", + operationName, + attempt, + MaxAiAttempts); + } + else if (lastResult.Outcome == AIOperationOutcome.InvalidOutput) + { + _logger.LogWarning( + "AI {OperationName} attempt {Attempt}/{MaxAttempts} returned invalid response shape; retrying", + operationName, + attempt, + MaxAiAttempts); + } + } + } + + _logger.LogWarning( + "AI {OperationName} exhausted retries with outcome {Outcome}; returning last result", + operationName, + lastResult.Outcome); + return lastResult; + } + + private static string ResolveNarrativeContent(AIOperationResult result) + { + return result.Outcome switch + { + AIOperationOutcome.Success => result.Content, + AIOperationOutcome.PermanentFailure => ServiceNotConfiguredMessage, + AIOperationOutcome.TransientFailure => ServiceTemporarilyUnavailableMessage, + _ => SummaryFailedRetryMessage + }; + } + + private static AIOperationResult MapFailureOutcome(HttpStatusCode statusCode, AIProviderResponse response) + { + var statusCodeValue = (int)statusCode; + + if (statusCode == HttpStatusCode.RequestTimeout + || statusCode == (HttpStatusCode)429 + || statusCodeValue >= 500) + { + return AIOperationResult.TransientFailure(response); + } + + return AIOperationResult.PermanentFailure(response); + } + + private static AIProviderResponse BuildProviderResponseFromMetadata(string content, string? rawResponse, AIProviderResponseMetadata? metadata) + { + return new AIProviderResponse( + content, + rawResponse ?? string.Empty, + metadata?.Model, + metadata?.FinishReason, + metadata?.PromptTokens, + metadata?.CompletionTokens, + metadata?.TotalTokens, + metadata?.ReasoningTokens); + } + + private static AIProviderResponseMetadata? TryExtractProviderMetadata(string? responseContent) + { + if (string.IsNullOrWhiteSpace(responseContent)) + { + return null; + } + + try + { + using var jsonDoc = JsonDocument.Parse(responseContent); + var root = jsonDoc.RootElement; + var model = root.TryGetProperty("model", out var modelProp) && modelProp.ValueKind == JsonValueKind.String + ? modelProp.GetString() + : null; + + string? finishReason = null; + if (root.TryGetProperty("choices", out var choices) + && choices.ValueKind == JsonValueKind.Array + && choices.GetArrayLength() > 0) + { + var firstChoice = choices[0]; + if (firstChoice.TryGetProperty("finish_reason", out var finishReasonProp) && finishReasonProp.ValueKind == JsonValueKind.String) + { + finishReason = finishReasonProp.GetString(); + } + } + + int? promptTokens = null; + int? completionTokens = null; + int? totalTokens = null; + int? reasoningTokens = null; + if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + promptTokens = TryGetInt32(usage, "prompt_tokens"); + completionTokens = TryGetInt32(usage, "completion_tokens"); + totalTokens = TryGetInt32(usage, "total_tokens"); + + if (usage.TryGetProperty("completion_tokens_details", out var completionTokenDetails) + && completionTokenDetails.ValueKind == JsonValueKind.Object) + { + reasoningTokens = TryGetInt32(completionTokenDetails, "reasoning_tokens"); + } + } + + return new AIProviderResponseMetadata(model, finishReason, promptTokens, completionTokens, totalTokens, reasoningTokens); + } + catch (JsonException) + { + return null; + } + } + + private void LogProviderMetadata(string? operationName, AIProviderResponse response) + { + if (string.IsNullOrWhiteSpace(response.Model) + && string.IsNullOrWhiteSpace(response.FinishReason) + && response.PromptTokens == null + && response.CompletionTokens == null + && response.TotalTokens == null + && response.ReasoningTokens == null) + { + return; + } + + _logger.LogDebug( + "AI provider response metadata for {OperationName}: Model={Model}, FinishReason={FinishReason}, PromptTokens={PromptTokens}, CompletionTokens={CompletionTokens}, TotalTokens={TotalTokens}, ReasoningTokens={ReasoningTokens}", + operationName ?? "completion", + response.Model, + response.FinishReason, + response.PromptTokens, + response.CompletionTokens, + response.TotalTokens, + response.ReasoningTokens); + } + + private static int? TryGetInt32(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var property) + && property.ValueKind == JsonValueKind.Number + && property.TryGetInt32(out var value) + ? value + : null; + } + + private static string ResolveMaxTokensParameterName(string? configuredParameterName) + { + if (string.Equals(configuredParameterName, LegacyMaxTokensParameterName, StringComparison.Ordinal)) + { + return LegacyMaxTokensParameterName; + } + + return DefaultMaxTokensParameterName; + } + + private int ResolveCompletionTokens(string operationName, int defaultValue) + { + var configuredValue = _configuration.GetValue($"Azure:Operations:{operationName}:MaxCompletionTokens"); + if (configuredValue is > 0) + { + return configuredValue.Value; + } + + var defaultConfiguredValue = _configuration.GetValue("Azure:Operations:Defaults:MaxCompletionTokens"); + return defaultConfiguredValue is > 0 ? defaultConfiguredValue.Value : defaultValue; + } + + private string? ResolvePromptVersionSetting(string operationName) + { + var operationPromptVersion = _configuration[$"Azure:Operations:{operationName}:PromptVersion"]; + if (!string.IsNullOrWhiteSpace(operationPromptVersion)) + { + return operationPromptVersion; + } + + var defaultPromptVersion = _configuration["Azure:Operations:Defaults:PromptVersion"]; + if (!string.IsNullOrWhiteSpace(defaultPromptVersion)) + { + return defaultPromptVersion; + } + + return _configuration["Azure:OpenAI:PromptVersion"]; + } + + private string ResolveProviderName(string? operationName = null) + { + if (!string.IsNullOrWhiteSpace(operationName)) + { + var configuredProvider = _configuration[$"Azure:Operations:{operationName}:Provider"]; + if (!string.IsNullOrWhiteSpace(configuredProvider)) + { + return configuredProvider.Trim(); + } + } + + var defaultProvider = _configuration["Azure:Operations:Defaults:Provider"]; + return string.IsNullOrWhiteSpace(defaultProvider) ? DefaultProviderName : defaultProvider.Trim(); + } + + private string? ResolveApiKey(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + return _configuration[$"Azure:{providerName}:ApiKey"]; + } + + private string ResolveMaxTokensParameterNameForOperation(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileParameterName = ResolveProfileSetting(providerName, profileName, "MaxTokensParameter"); + return ResolveMaxTokensParameterName(profileParameterName); + } + + private double? ResolveConfiguredTemperature(string? operationName = null) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileTemperature = ResolveProfileSetting(providerName, profileName, "Temperature"); + if (profileTemperature != null + && double.TryParse(profileTemperature, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var parsedTemperature)) + { + return parsedTemperature; + } + + return null; + } + + private string ResolveApiUrl(string? operationName) + { + var providerName = ResolveProviderName(operationName); + var profileName = ResolveProfileName(operationName); + var profileApiUrl = ResolveProfileSetting(providerName, profileName, "ApiUrl"); + var legacyOpenAiApiUrl = _configuration["Azure:OpenAI:ApiUrl"]; + + if (!string.IsNullOrWhiteSpace(profileApiUrl)) + { + return profileApiUrl; + } + + if (!string.IsNullOrWhiteSpace(legacyOpenAiApiUrl)) + { + return legacyOpenAiApiUrl; + } + + throw new InvalidOperationException($"AI API URL is not configured for provider '{providerName}'."); + } + + private string? ResolveProfileName(string? operationName) + { + if (!string.IsNullOrWhiteSpace(operationName)) + { + var operationProfile = _configuration[$"Azure:Operations:{operationName}:Profile"]; + if (!string.IsNullOrWhiteSpace(operationProfile)) + { + return operationProfile.Trim(); + } + } + + var defaultProfile = _configuration["Azure:Operations:Defaults:Profile"]; + return string.IsNullOrWhiteSpace(defaultProfile) ? null : defaultProfile.Trim(); + } + + private string? ResolveProfileSetting(string providerName, string? profileName, string settingName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + return null; + } + + var profileSetting = _configuration[$"Azure:{providerName}:Profiles:{profileName}:{settingName}"]; + return string.IsNullOrWhiteSpace(profileSetting) ? null : profileSetting; + } + private static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(string raw) { var response = new ApplicationAnalysisResponse(); @@ -668,8 +1054,7 @@ private void SavePromptCapture(bool capturePromptIo, string? contextId, string p CaptureLabel = captureLabel?.Trim() ?? string.Empty, SystemPrompt = systemPrompt?.Trim() ?? string.Empty, UserPrompt = userPrompt?.Trim() ?? string.Empty, - RawOutput = rawOutput?.Trim() ?? string.Empty, - FormattedOutput = FormatPromptOutputForLog(rawOutput ?? string.Empty), + Output = FormatPromptOutputForLog(rawOutput ?? string.Empty), CapturedAt = DateTime.UtcNow }); } @@ -688,6 +1073,11 @@ private static string FormatPromptOutputForLog(string output) return string.Empty; } + if (TryFormatProviderOutput(output, out var formattedProviderOutput)) + { + return formattedProviderOutput; + } + if (TryParseJsonObjectFromResponse(output, out var jsonObject)) { return JsonSerializer.Serialize(jsonObject, JsonLogOptions); @@ -696,24 +1086,63 @@ private static string FormatPromptOutputForLog(string output) return output.Trim(); } - private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) + private static bool TryFormatProviderOutput(string output, out string formattedOutput) { - objectElement = default; - var cleaned = CleanJsonResponse(response); - if (string.IsNullOrWhiteSpace(cleaned)) - { - return false; - } + formattedOutput = string.Empty; try { - using var doc = JsonDocument.Parse(cleaned); - if (doc.RootElement.ValueKind != JsonValueKind.Object) + using var doc = JsonDocument.Parse(output); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("choices", out var choices) + || choices.ValueKind != JsonValueKind.Array + || choices.GetArrayLength() == 0) { return false; } - objectElement = doc.RootElement.Clone(); + var firstChoice = choices[0]; + var content = TryGetChoiceContent(firstChoice); + if (string.IsNullOrWhiteSpace(content)) + { + return false; + } + + var lines = new List(); + + if (root.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object) + { + var promptTokens = TryGetInt32(usage, "prompt_tokens"); + var completionTokens = TryGetInt32(usage, "completion_tokens"); + int? reasoningTokens = null; + + if (usage.TryGetProperty("completion_tokens_details", out var completionTokenDetails) + && completionTokenDetails.ValueKind == JsonValueKind.Object) + { + reasoningTokens = TryGetInt32(completionTokenDetails, "reasoning_tokens"); + } + + if (promptTokens.HasValue) + { + lines.Add($"PROMPT TOKENS: {promptTokens.Value}"); + } + + if (completionTokens.HasValue) + { + lines.Add($"COMPLETION TOKENS: {completionTokens.Value}"); + } + + if (reasoningTokens.HasValue) + { + lines.Add($"REASONING TOKENS: {reasoningTokens.Value}"); + } + } + + var normalizedContent = FormatPromptOutputContent(content); + lines.Add("CONTENT:"); + lines.Add(normalizedContent); + formattedOutput = string.Join(Environment.NewLine, lines); return true; } catch (JsonException) @@ -722,64 +1151,55 @@ private static bool TryParseJsonObjectFromResponse(string response, out JsonElem } } - private static string CleanJsonResponse(string response) + private static string? TryGetChoiceContent(JsonElement firstChoice) { - if (string.IsNullOrWhiteSpace(response)) + if (!firstChoice.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.Object) { - return string.Empty; + return null; } - var cleaned = response.Trim(); - - if (cleaned.StartsWith("```json", StringComparison.OrdinalIgnoreCase) || cleaned.StartsWith("```")) + if (!message.TryGetProperty("content", out var contentProp) || contentProp.ValueKind != JsonValueKind.String) { - 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..]; - } - } + return null; } - if (cleaned.EndsWith("```", StringComparison.Ordinal)) + return contentProp.GetString(); + } + + private static string FormatPromptOutputContent(string content) + { + if (TryParseJsonObjectFromResponse(content, out var contentObject)) { - var lastIndex = cleaned.LastIndexOf("```", StringComparison.Ordinal); - if (lastIndex > 0) - { - cleaned = cleaned[..lastIndex]; - } + return JsonSerializer.Serialize(contentObject, JsonLogOptions); } - return cleaned.Trim(); + return content.Trim(); } - private static int FindFirstJsonTokenIndex(string value) + private static bool TryParseJsonObjectFromResponse(string response, out JsonElement objectElement) { - var objectStart = value.IndexOf('{'); - var arrayStart = value.IndexOf('['); - - if (objectStart >= 0 && arrayStart >= 0) + objectElement = default; + var cleaned = AIResponseJson.CleanJsonResponse(response); + if (string.IsNullOrWhiteSpace(cleaned)) { - return Math.Min(objectStart, arrayStart); + return false; } - if (objectStart >= 0) + try + { + using var doc = JsonDocument.Parse(cleaned); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + objectElement = doc.RootElement.Clone(); + return true; + } + catch (JsonException) { - return objectStart; + return false; } - - return arrayStart; } private static string ResolvePromptVersion(string? version) @@ -1039,3 +1459,5 @@ private static string ExtractSummaryFromJson(string output) } } } + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs index 8bea35cab0..ec60077961 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/PromptDataPayloadBuilder.cs @@ -147,13 +147,8 @@ private static Dictionary BuildPromptDataValues(JsonElement if (allowedSchemaKeys.Count > 0) { - foreach (var key in values.Keys.ToList()) - { - if (!allowedSchemaKeys.Contains(key)) - { - values.Remove(key); - } - } + values = values.Where(kvp => allowedSchemaKeys.Contains(kvp.Key)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase); } return values; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md index cc8d06ef5f..0a2ae41b7b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/README.md @@ -40,7 +40,8 @@ Placeholders: Version selection: -- `Azure:OpenAI:PromptVersion = v0|v1` +- Preferred: `Azure:Operations:Defaults:PromptVersion = v0|v1`, with optional overrides under `Azure:Operations::PromptVersion` +- Legacy fallback: `Azure:OpenAI:PromptVersion = v0|v1` - Unknown or missing version defaults to `v1`. Template loading is strict: diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt index b13f42bddb..a250310372 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.rules.txt @@ -1,31 +1,34 @@ - Use only provided input sections 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. -- Ignore evidence that is not relevant to a reviewer-facing conclusion. - Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. +- Treat missing or empty values as findings only when they weaken rubric evidence. +- Prefer material findings; avoid nitpicking. - Do not restate basic application facts as findings unless they support a specific reviewer conclusion about readiness, feasibility, budget credibility, eligibility, or confidence in proceeding. -- Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. -- Use 3-6 words for title. -- Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. -- Each detail must be 1-2 complete sentences. +- Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. +- If ATTACHMENTS evidence is used, cite the attachment by name in detail. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. -- When citing a positive conclusion, explain why that evidence matters for readiness, feasibility, or funding confidence. +- Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. +- Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. +- Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. +- Do not state that one amount exceeds, matches, or conflicts with another unless the comparison is directly supported by the provided values. +- Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - Prefer neutral evidence descriptions over evaluative adjectives unless the evidence directly supports a strong conclusion. - Do not describe capacity, feasibility, or justification as strong, detailed, or well-supported unless the evidence shows more than the existence of basic organizational, budget, or timeline information. - Do not infer community support, established partnerships, or delivery capacity from a single partner reference, staff count, or basic organizational status alone. - Do not describe a timeline as realistic or feasible based only on start and end dates unless additional evidence supports deliverability. -- Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. -- If ATTACHMENTS evidence is used, cite the attachment by name in detail. +- Use 3-6 words for title. +- Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. +- Each detail must be 1-2 complete sentences. - Summaries and nextSteps must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. -- Avoid generic praise, generic checklist language, and repeated conclusions across lists. +- Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. -- Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - If no findings exist, return empty arrays. - Rating must be HIGH, MEDIUM, or LOW. - Use summaries for overall application quality/readiness synthesis. -- Use nextSteps for reviewer-facing follow-up actions or considerations before scoring or decision-making. -- Only include nextSteps when there is a specific evidence gap, inconsistency, or verification need; otherwise return an empty array. +- Use nextSteps for concrete reviewer-facing next actions based on the provided evidence. +- nextSteps may include proceeding with the normal review process when the application appears ready for that step. +- When evidence shows a meaningful gap, inconsistency, or uncertainty, use nextSteps for specific follow-up or verification actions. +- Return an empty array only when no concrete next action would help the reviewer. - recommendation.decision must be PROCEED or HOLD. - Use HOLD only when provided evidence shows a material eligibility, feasibility, budget, or readiness concern that would reasonably block scoring or decision-making. - recommendation.rationale must explain the high-level recommendation in 1-2 complete sentences using provided evidence. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt index a35dd58acf..cfd2fe2d47 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/analysis.system.txt @@ -1,10 +1,9 @@ ROLE -You are a careful grant analyst assistant for human reviewers. You do not fill gaps or turn weak signals into strong reviewer conclusions. +You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. TASK Using SCHEMA, DATA, ATTACHMENTS, RUBRIC, SCORE, OUTPUT, and RULES: -1. Identify the strongest reviewer-relevant evidence in the application and attachments. +1. Review the application and attachments for the strongest reviewer-relevant evidence. 2. Determine which conclusions are directly supported by that evidence. 3. Exclude weak, repetitive, or loosely supported conclusions. -4. Before finalizing each conclusion, ask whether the evidence directly supports it and whether a more neutral description would be more accurate. -5. Return only the strongest evidence-backed reviewer conclusions. +4. Return only the strongest evidence-backed reviewer conclusions. \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt index 8008ef059a..2230e39228 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.rules.txt @@ -1,10 +1,13 @@ - Use only ATTACHMENT as evidence. - Summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary. -- Ignore attachment details that are not relevant to describing what the file contains or contributes. -- Describe the attachment itself, including its apparent function or content type when supported by the evidence, rather than summarizing the overall project. +- Describe the attachment itself rather than summarizing the overall project. +- Ensure the summary describes the attachment itself, not the overall project. - If ATTACHMENT.text is primarily structured application, contact, organization, budget, or date fields, summarize it as a metadata-style attachment rather than rewriting it as a generic project summary. +- Begin with what the attachment contains or provides, not the file name or file type, unless that metadata is necessary to describe the evidence. - Do not invent missing details. - Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. +- Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as ATTACHMENT or ATTACHMENT.text. +- Refer to evidence by its plain-language meaning, quoted text, or file name rather than internal key names. - Write 1-2 complete sentences. - Summary must be grounded in concrete ATTACHMENT evidence. - Return exactly one object with only the key: summary. diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt index 525825366e..50f0d6a6f3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/attachment.system.txt @@ -1,10 +1,8 @@ ROLE -You are a careful grant analyst assistant for human reviewers. You do not fill gaps or summarize the overall project when the attachment itself is the evidence. +You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. TASK Using ATTACHMENT, OUTPUT, and RULES: -1. Identify what the attachment contains. -2. Determine what type of attachment it appears to be, when the evidence supports that. -3. Summarize only the attachment-specific content or evidence it provides. -4. Before finalizing the summary, check that it describes the attachment itself and not the overall project. -5. Return a concise reviewer-facing summary. +1. Review the attachment to identify what it contains. +2. Summarize the attachment itself, not the overall project. +3. Return a concise reviewer-facing summary. \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt index bbdaaf55c2..81a4132069 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.rules.txt @@ -1,5 +1,15 @@ - Use only DATA and ATTACHMENTS as evidence. - Do not invent missing application details. +- Ignore fields or details that are not relevant to the specific question being answered. +- Prefer, in order: direct evidence of the exact condition asked, closely related supporting evidence, then general context only when necessary. +- If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. +- Do not convert general project descriptions into evidence for a specific scored condition unless that condition is directly supported. +- Treat prefilled labels, ratings, rankings, or statuses as background context only unless the question explicitly asks for that same item. +- Do not treat related concepts as equivalent; answer the specific question asked, not a nearby concept. +- Do not infer unsupported claims about requirements, conditions, relationships, compliance elements, mitigations, supports, or outcomes. +- Answer a specific condition positively only when that exact condition is directly evidenced in DATA or ATTACHMENTS. +- For eligibility, completeness, ownership, location, or compliance questions, do not answer positively unless the exact condition is directly confirmed in the provided evidence. +- If the evidence shows only involvement, presence, relevance, or association, do not treat that alone as proof that a requirement or condition is satisfied. - Return exactly one answer object per question ID in SECTION.questions. - Do not omit any question IDs from SECTION.questions. - Do not add keys that are not question IDs from SECTION.questions. @@ -9,16 +19,10 @@ - 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 grounded in concrete DATA/ATTACHMENTS evidence. -- In rationale, cite concrete source evidence from the provided input content rather than prompt section headers. +- In rationale, cite concrete source evidence from the provided input content in plain language rather than prompt section headers or internal field names. +- Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. +- Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. - For every question, rationale must justify both the selected answer and the selected confidence level based on evidence strength. -- If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. -- Ignore fields or details that are not relevant to the specific question being answered. -- Prefer, in order: direct evidence of the exact condition asked, closely related supporting evidence, then general context only when necessary. -- Do not convert general project descriptions into evidence for a specific scored condition unless that condition is directly supported. -- Treat prefilled labels, ratings, rankings, or statuses in DATA as background context only; do not use them as evidence unless the question explicitly asks you to report that same item. -- Do not use one field's prior classification, rating, or judgment as evidence for a different question unless the question explicitly asks for that same classification, rating, or judgment. -- Do not treat related concepts as equivalent; answer the specific question asked, not a nearby concept. -- Do not infer unsupported claims about requirements, conditions, relationships, compliance elements, mitigations, supports, or outcomes. - 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. - Do not use maximum or near-maximum confidence when the answer depends on inference rather than an explicit statement of the exact condition. @@ -35,18 +39,12 @@ - If evidence supports the existence of a topic but not the required strength, completeness, or specificity, choose the lowest option consistent with that evidence. - If evidence is insufficient for a select list question, choose the lowest allowed answer value from question.allowed_answers and explain the uncertainty. - Do not treat broad project descriptions, general goals, high-level timelines, budget presence, or a single indirect reference as sufficient evidence for a higher-scored select-list answer. -- Answer a specific condition positively only when that exact condition is directly evidenced in DATA or ATTACHMENTS. -- If the evidence shows only involvement, presence, relevance, or association, do not treat that alone as proof that a requirement or condition is satisfied. -- If a question asks whether something is eligible, complete, appropriate, or satisfied, require direct evidence of that exact condition rather than general relevance or presence. - 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. - If no concerns are identified for a text or text area question, return a short non-empty evidence-based comment rather than leaving answer blank. - For comment fields, summarize only the evidence-based conclusions supported by the scored answers, including uncertainty where applicable, and do not introduce stronger claims. -- For narrative comment fields, keep wording aligned with the scored answers and evidence; do not use stronger certainty or impact language than the evidence supports. - For comment fields, describe the evidence and resulting answer without elevating it into an overall assessment unless the question explicitly asks for one. -- Do not treat the presence of a named person, partner, organization, location, document, or field as proof of a separate requirement, condition, or relationship unless that exact point is explicitly evidenced. - Do not add recommendations or stronger conclusions unless the question explicitly asks for them. -- For comment fields, always provide a concise evidence-based summary even when no concerns are identified. - For comment fields, do not leave answer empty even when all other answers are positive. - 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". diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt index 855286f824..297a9f351f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/Prompts/Versions/v1/scoresheet.system.txt @@ -1,5 +1,5 @@ ROLE -You are a careful grant review assistant for human reviewers. You do not fill gaps, assume compliance, or treat relevance as proof. +You are a careful grant review assistant for human reviewers. Do not fill gaps, assume compliance, or treat relevance as proof. TASK Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: @@ -8,5 +8,4 @@ Using DATA, ATTACHMENTS, SECTION, RESPONSE, OUTPUT, and RULES: 3. Consider only the most relevant evidence in DATA and ATTACHMENTS for that condition. 4. Choose the most conservative valid answer supported by that evidence. 5. If evidence is incomplete or indirect, explain the uncertainty in the rationale. -6. Before finalizing each answer, ask: "What exact evidence supports this condition?" If no direct evidence exists, choose the most conservative valid answer. -7. Repeat for every question in SECTION. +6. Repeat for every question in SECTION. \ No newline at end of file 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 54df521637..c45eeb9d36 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/AI/TextExtractionService.cs @@ -2,12 +2,14 @@ using NPOI.SS.UserModel; using NPOI.XWPF.UserModel; using System; -using System.Collections.Generic; using System.IO; +using System.IO.Compression; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml.Linq; using UglyToad.PdfPig; using Volo.Abp.DependencyInjection; @@ -22,6 +24,7 @@ public partial class TextExtractionService : ITextExtractionService, ITransientD private const int MaxDocxParagraphs = 2000; private const int MaxDocxTableRows = 2000; private const int MaxDocxTableCellsPerRow = 50; + private const int MaxPowerPointSlides = 200; private readonly ILogger _logger; private readonly Dictionary> _extractorsByExtension; @@ -37,7 +40,8 @@ public TextExtractionService(ILogger logger) [".pdf"] = ExtractTextFromPdfFile, [".docx"] = ExtractTextFromWordDocx, [".xls"] = ExtractTextFromExcelFile, - [".xlsx"] = ExtractTextFromExcelFile + [".xlsx"] = ExtractTextFromExcelFile, + [".pptx"] = ExtractTextFromPowerPointFile }; } @@ -92,6 +96,13 @@ public Task ExtractTextAsync(string fileName, byte[] fileContent, string return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); } + if (normalizedContentType.Contains("presentation") || + normalizedContentType.Contains("powerpoint")) + { + var rawText = ExtractTextFromPowerPointFile(fileName, fileContent); + return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + } + _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", contentType, extension); return Task.FromResult(string.Empty); @@ -120,6 +131,7 @@ private string ExtractTextFromTextFile(byte[] fileContent) _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); } + _logger.LogDebug("Extracted {CharacterCount} characters from text-based content.", text.Length); return text; } catch (Exception ex) @@ -136,12 +148,26 @@ private string ExtractTextFromPdfFile(string fileName, byte[] fileContent) using var stream = new MemoryStream(fileContent, writable: false); using var document = PdfDocument.Open(stream); var builder = new StringBuilder(); + var processedPageCount = 0; var pageTexts = document.GetPages() .Select(page => page.Text) .Where(pageText => !string.IsNullOrWhiteSpace(pageText)); - AppendUntilLimit(builder, pageTexts); + foreach (var pageText in pageTexts) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + processedPageCount++; + if (TryAppendWithTrailingNewline(builder, pageText)) + { + break; + } + } + _logger.LogDebug("Extracted PDF text from {ProcessedPageCount} pages for {FileName}", processedPageCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -158,15 +184,14 @@ private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) using var stream = new MemoryStream(fileContent, writable: false); using var document = new XWPFDocument(stream); var builder = new StringBuilder(); - var paragraphTexts = document.Paragraphs - .Take(MaxDocxParagraphs) - .Select(paragraph => paragraph.ParagraphText) - .Where(paragraphText => !string.IsNullOrWhiteSpace(paragraphText)); - - AppendUntilLimit(builder, paragraphTexts); - - TryAppendDocxTableText(document, builder); - + var processedParagraphCount = AppendDocxParagraphText(document, builder); + var processedTableRowCount = AppendDocxTableText(document, builder); + + _logger.LogDebug( + "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", + processedParagraphCount, + processedTableRowCount, + fileName); return builder.ToString(); } catch (Exception ex) @@ -176,28 +201,71 @@ private string ExtractTextFromWordDocx(string fileName, byte[] fileContent) } } - private static void TryAppendDocxTableText(XWPFDocument document, StringBuilder builder) + private static int AppendDocxParagraphText(XWPFDocument document, StringBuilder builder) + { + var processedParagraphCount = 0; + var paragraphTexts = document.Paragraphs + .Take(MaxDocxParagraphs) + .Select(paragraph => paragraph.ParagraphText) + .Where(paragraphText => !string.IsNullOrWhiteSpace(paragraphText)); + + foreach (var paragraphText in paragraphTexts) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + processedParagraphCount++; + if (TryAppendWithTrailingNewline(builder, paragraphText)) + { + break; + } + } + + return processedParagraphCount; + } + + private static int AppendDocxTableText(XWPFDocument document, StringBuilder builder) { if (builder.Length >= MaxExtractedTextLength) { - return; + return 0; } + var processedTableRowCount = 0; foreach (var table in document.Tables) { foreach (var row in table.Rows.Take(MaxDocxTableRows)) { + if (builder.Length >= MaxExtractedTextLength) + { + return processedTableRowCount; + } + var cellTexts = row.GetTableCells() .Take(MaxDocxTableCellsPerRow) .Select(cell => cell.GetText()) .Where(cellText => !string.IsNullOrWhiteSpace(cellText)); - if (AppendUntilLimit(builder, cellTexts)) + var rowHadValue = false; + foreach (var cellText in cellTexts) { - return; + rowHadValue = true; + if (TryAppendWithTrailingNewline(builder, cellText)) + { + return processedTableRowCount + 1; + } + } + + if (rowHadValue) + { + processedTableRowCount++; } } } + + return processedTableRowCount; } private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) @@ -208,6 +276,8 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) using var workbook = WorkbookFactory.Create(stream); var builder = new StringBuilder(); var sheetCount = Math.Min(workbook.NumberOfSheets, MaxExcelSheets); + var processedSheetCount = 0; + var processedRowCount = 0; for (var sheetIndex = 0; sheetIndex < sheetCount; sheetIndex++) { @@ -217,13 +287,24 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) } var sheet = workbook.GetSheetAt(sheetIndex); - var limitReached = TryAppendExcelSheet(sheet, builder); + var (rowsProcessed, limitReached) = TryAppendExcelSheet(sheet, builder); + if (rowsProcessed > 0) + { + processedSheetCount++; + processedRowCount += rowsProcessed; + } + if (limitReached) { break; } } + _logger.LogDebug( + "Extracted Excel text from {ProcessedSheetCount} sheets and {ProcessedRowCount} rows for {FileName}", + processedSheetCount, + processedRowCount, + fileName); return builder.ToString(); } catch (Exception ex) @@ -233,11 +314,94 @@ private string ExtractTextFromExcelFile(string fileName, byte[] fileContent) } } - private static bool TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) + private string ExtractTextFromPowerPointFile(string fileName, byte[] fileContent) + { + try + { + using var stream = new MemoryStream(fileContent, writable: false); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false); + var builder = new StringBuilder(); + var slideEntries = GetOrderedPowerPointSlideEntries(archive) + .Take(MaxPowerPointSlides); + var processedSlideCount = 0; + + foreach (var slideEntry in slideEntries) + { + if (builder.Length >= MaxExtractedTextLength) + { + break; + } + + using var slideStream = slideEntry.Open(); + var slideText = ExtractPowerPointSlideText(slideStream); + if (string.IsNullOrWhiteSpace(slideText)) + { + continue; + } + + processedSlideCount++; + if (TryAppendWithTrailingNewline(builder, slideText)) + { + break; + } + } + + _logger.LogDebug("Extracted PowerPoint text from {ProcessedSlideCount} slides for {FileName}", processedSlideCount, fileName); + return builder.ToString(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PowerPoint (.pptx) text extraction failed for {FileName}", fileName); + return string.Empty; + } + } + + private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive archive) + { + var slideEntriesByName = archive.Entries + .Where(entry => entry.FullName.StartsWith("ppt/slides/slide", StringComparison.OrdinalIgnoreCase) && + entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(entry => entry.FullName, StringComparer.OrdinalIgnoreCase); + + if (slideEntriesByName.Count == 0) + { + _logger.LogDebug("No slide entries found in PowerPoint archive."); + return Enumerable.Empty(); + } + + var orderedSlideNames = TryGetPowerPointSlideOrder(archive); + if (orderedSlideNames.Count == 0) + { + _logger.LogDebug("Using PowerPoint part-name order fallback for {SlideCount} slides.", slideEntriesByName.Count); + return slideEntriesByName.Values + .OrderBy(entry => GetPowerPointSlideNumber(entry.FullName)) + .ToList(); + } + + var orderedEntries = new List(slideEntriesByName.Count); + foreach (var slideName in orderedSlideNames) + { + if (slideEntriesByName.TryGetValue(slideName, out var slideEntry)) + { + orderedEntries.Add(slideEntry); + slideEntriesByName.Remove(slideName); + } + } + + if (slideEntriesByName.Count > 0) + { + orderedEntries.AddRange(slideEntriesByName.Values.OrderBy(entry => GetPowerPointSlideNumber(entry.FullName))); + } + + _logger.LogDebug("Resolved PowerPoint presentation order for {SlideCount} slides.", orderedEntries.Count); + return orderedEntries; + } + + private static (int RowsProcessed, bool LimitReached) TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) { if (sheet == null) { - return false; + return (0, false); } var processedRows = 0; @@ -248,18 +412,22 @@ private static bool TryAppendExcelSheet(ISheet? sheet, StringBuilder builder) break; } - var limitReached = TryAppendExcelRow(row, builder); - processedRows++; + var (rowHadValue, limitReached) = TryAppendExcelRow(row, builder); + if (rowHadValue) + { + processedRows++; + } + if (limitReached) { - return true; + return (processedRows, true); } } - return builder.Length >= MaxExtractedTextLength; + return (processedRows, builder.Length >= MaxExtractedTextLength); } - private static bool TryAppendExcelRow(IRow row, StringBuilder builder) + private static (bool RowHadValue, bool LimitReached) TryAppendExcelRow(IRow row, StringBuilder builder) { var rowHasValue = false; foreach (var cell in row.Cells.Take(MaxExcelCellsPerRow)) @@ -280,7 +448,7 @@ private static bool TryAppendExcelRow(IRow row, StringBuilder builder) rowHasValue = true; if (limitReached) { - return true; + return (true, true); } } @@ -290,7 +458,7 @@ private static bool TryAppendExcelRow(IRow row, StringBuilder builder) builder.Append(Environment.NewLine); } - return builder.Length >= MaxExtractedTextLength; + return (rowHasValue, builder.Length >= MaxExtractedTextLength); } private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? value) @@ -309,10 +477,108 @@ private static bool TryAppendWithTrailingNewline(StringBuilder builder, string? return builder.Length >= MaxExtractedTextLength; } - private static bool AppendUntilLimit(StringBuilder builder, IEnumerable texts) + private static string ExtractPowerPointSlideText(Stream slideStream) { - var limitReached = texts.Any(text => TryAppendWithTrailingNewline(builder, text)); - return limitReached || builder.Length >= MaxExtractedTextLength; + var document = XDocument.Load(slideStream); + XNamespace drawingNamespace = "http://schemas.openxmlformats.org/drawingml/2006/main"; + var textRuns = document + .Descendants(drawingNamespace + "t") + .Select(node => node.Value?.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value)); + + return string.Join(Environment.NewLine, textRuns); + } + + private static int GetPowerPointSlideNumber(string entryName) + { + var fileName = Path.GetFileNameWithoutExtension(entryName); + if (string.IsNullOrWhiteSpace(fileName)) + { + return int.MaxValue; + } + + var slideNumberText = fileName.Substring("slide".Length); + return int.TryParse(slideNumberText, out var slideNumber) + ? slideNumber + : int.MaxValue; + } + + private List TryGetPowerPointSlideOrder(ZipArchive archive) + { + try + { + var presentationEntry = archive.GetEntry("ppt/presentation.xml"); + var relationshipsEntry = archive.GetEntry("ppt/_rels/presentation.xml.rels"); + if (presentationEntry == null || relationshipsEntry == null) + { + return new List(); + } + + using var presentationStream = presentationEntry.Open(); + using var relationshipsStream = relationshipsEntry.Open(); + var presentationDocument = XDocument.Load(presentationStream); + var relationshipsDocument = XDocument.Load(relationshipsStream); + + XNamespace presentationNamespace = "http://schemas.openxmlformats.org/presentationml/2006/main"; + XNamespace officeDocumentRelationshipsNamespace = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + XNamespace packageRelationshipsNamespace = "http://schemas.openxmlformats.org/package/2006/relationships"; + + var slideTargetsByRelationshipId = (relationshipsDocument + .Root? + .Elements(packageRelationshipsNamespace + "Relationship") + .Where(element => string.Equals( + element.Attribute("Type")?.Value, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide", + StringComparison.OrdinalIgnoreCase)) + .Select(element => new + { + Id = element.Attribute("Id")?.Value, + Target = NormalizePowerPointSlideTarget(element.Attribute("Target")?.Value) + }) + .Where(item => !string.IsNullOrWhiteSpace(item.Id) && !string.IsNullOrWhiteSpace(item.Target)) + .ToDictionary(item => item.Id!, item => item.Target!, StringComparer.OrdinalIgnoreCase)) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + + return presentationDocument + .Descendants(presentationNamespace + "sldId") + .Select(element => element.Attribute(officeDocumentRelationshipsNamespace + "id")?.Value) + .Where(relationshipId => !string.IsNullOrWhiteSpace(relationshipId)) + .Select(relationshipId => slideTargetsByRelationshipId.GetValueOrDefault(relationshipId!)) + .Where(target => !string.IsNullOrWhiteSpace(target)) + .Cast() + .ToList(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Falling back to part-name slide order for PowerPoint extraction."); + return new List(); + } + } + + private static string? NormalizePowerPointSlideTarget(string? target) + { + if (string.IsNullOrWhiteSpace(target)) + { + return null; + } + + var normalizedTarget = target.Replace('\\', '/').TrimStart('/'); + if (normalizedTarget.StartsWith("ppt/", StringComparison.OrdinalIgnoreCase)) + { + return normalizedTarget; + } + + if (normalizedTarget.StartsWith("slides/", StringComparison.OrdinalIgnoreCase)) + { + return $"ppt/{normalizedTarget}"; + } + + if (normalizedTarget.StartsWith("../", StringComparison.OrdinalIgnoreCase)) + { + normalizedTarget = normalizedTarget.Substring(3); + } + + return $"ppt/{normalizedTarget}"; } private static void AppendTrailingNewlineIfRoom(StringBuilder builder) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs index 0fb17d0f99..fc73e7f825 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/ApplicantProfile/PaymentInfoDataProvider.cs @@ -61,7 +61,7 @@ join application in applicationsQuery on submission.ApplicationId equals applica dto.Payments.AddRange(paymentDetails.Select(p => new PaymentInfoItemDto { Id = p.Id, - PaymentNumber = p.PaymentNumber ?? string.Empty, + PaymentNumber = p.InvoiceNumber, ReferenceNo = applicationLookup.TryGetValue(p.CorrelationId, out var refNo) ? refNo : string.Empty, Amount = p.Amount, PaymentDate = p.PaymentDate, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs index 251105e310..9b541dadf9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/ApplicantAppService.cs @@ -578,12 +578,26 @@ private async Task UpdateAddress(List applicantAddresses, Addr } + [RemoteService(true)] + public async Task TransferApplicantApplicationsAsync(TransferApplicantApplicationsDto dto) + { + var applications = await applicationRepository.GetByApplicantIdAsync(dto.NonPrincipalApplicantId); + foreach (var application in applications) + { + await UpdateApplicantIdAsync(new UpdateApplicantIdDto + { + ApplicationId = application.Id, + ApplicantId = dto.PrincipalApplicantId + }); + } + } + [RemoteService(true)] public async Task SetDuplicatedAsync(SetApplicantDuplicateDto dto) { // Set principal as not duplicated var principal = await applicantRepository.GetAsync(dto.PrincipalApplicantId); - if (principal != null && principal.IsDuplicated != false) + if (principal != null && principal.IsDuplicated) { principal.IsDuplicated = false; await applicantRepository.UpdateAsync(principal); @@ -591,7 +605,7 @@ public async Task SetDuplicatedAsync(SetApplicantDuplicateDto dto) // Set non-principal as duplicated var nonPrincipal = await applicantRepository.GetAsync(dto.NonPrincipalApplicantId); - if (nonPrincipal != null && nonPrincipal.IsDuplicated != true) + if (nonPrincipal != null && !nonPrincipal.IsDuplicated) { nonPrincipal.IsDuplicated = true; await applicantRepository.UpdateAsync(nonPrincipal); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/TransferApplicantApplicationsDto.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/TransferApplicantApplicationsDto.cs new file mode 100644 index 0000000000..f767ededd8 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Applicants/TransferApplicantApplicationsDto.cs @@ -0,0 +1,9 @@ +using System; + +namespace Unity.GrantManager.Applicants; + +public class TransferApplicantApplicationsDto +{ + public Guid PrincipalApplicantId { get; set; } + public Guid NonPrincipalApplicantId { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs index bd12f4410f..a9398d1547 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Attachments/AttachmentAppService.cs @@ -5,14 +5,17 @@ using System.Linq.Expressions; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.AI; using Unity.GrantManager.Applications; using Unity.GrantManager.Identity; using Unity.GrantManager.Intakes; +using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; +using Volo.Abp.Features; namespace Unity.GrantManager.Attachments; @@ -26,7 +29,8 @@ public class AttachmentAppService( IIntakeFormSubmissionManager intakeFormSubmissionManager, IPersonRepository personUserRepository, IAIService aiService, - ISubmissionAppService submissionAppService) : ApplicationService, IAttachmentAppService + ISubmissionAppService submissionAppService, + IFeatureChecker featureChecker) : ApplicationService, IAttachmentAppService { private const string DefaultContentType = "application/octet-stream"; private const string SummaryGenerationFailedMessage = "AI summary generation failed."; @@ -189,8 +193,14 @@ protected internal static async Task UpdateMetadataIntern return attachment.CreatorId; } + [Authorize(AIPermissions.AttachmentSummary.AttachmentSummaryDefault)] public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId, string? promptVersion = null, bool capturePromptIo = false) { + if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) + { + throw new UserFriendlyException("AI attachment summaries are not enabled."); + } + if (!await aiService.IsAvailableAsync()) { Logger.LogWarning("AI service is not available for attachment summary generation. AttachmentId: {AttachmentId}", attachmentId); @@ -217,8 +227,14 @@ public async Task GenerateAISummaryAttachmentAsync(Guid attachmentId, st return summaryResponse.Summary; } + [Authorize(AIPermissions.AttachmentSummary.AttachmentSummaryDefault)] public async Task> GenerateAISummariesAttachmentsAsync(List attachmentIds, string? promptVersion = null, bool capturePromptIo = false) { + if (!await featureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries")) + { + throw new UserFriendlyException("AI attachment summaries are not enabled."); + } + if (!await aiService.IsAvailableAsync()) { Logger.LogWarning("AI service is not available for bulk attachment summary generation."); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs index 9858838ff8..2aa5d540d6 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIAnalysisAppService.cs @@ -1,19 +1,29 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.AI; using Volo.Abp; +using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications; +[Authorize(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault)] public class ApplicationAIAnalysisAppService( - IApplicationAnalysisService applicationAnalysisService) + IApplicationAnalysisService applicationAnalysisService, + IFeatureChecker featureChecker) : GrantManagerAppService, IApplicationAIAnalysisAppService { public async Task GenerateAIAnalysisAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { + if (!await featureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis")) + { + throw new UserFriendlyException("AI application analysis is not enabled."); + } + return await applicationAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); } catch (Exception ex) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs index 8780c833f3..14ededab94 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIPromptCaptureAppService.cs @@ -2,17 +2,23 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Unity.AI.Permissions; using Unity.GrantManager.AI; using Volo.Abp; +using Volo.Abp.Authorization; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications { public class ApplicationAIPromptCaptureAppService( IAIPromptCaptureStore promptIoCaptureStore, - IWebHostEnvironment webHostEnvironment) + IWebHostEnvironment webHostEnvironment, + IFeatureChecker featureChecker, + IPermissionChecker permissionChecker) : GrantManagerAppService, IApplicationAIPromptCaptureAppService { - public Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) + public async Task> GetRecentAsync(Guid applicationId, string promptType, string? promptVersion = null) { if (!string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase)) { @@ -21,11 +27,55 @@ public Task> GetRecentAsync(Guid applicationId, st if (string.IsNullOrWhiteSpace(promptType)) { - return Task.FromResult(new List()); + return new List(); } + await EnsurePromptCapturePermissionAsync(promptType); + await EnsurePromptCaptureFeatureEnabledAsync(promptType); var captures = promptIoCaptureStore.GetRecent(applicationId.ToString(), promptType, promptVersion); - return Task.FromResult(new List(captures)); + return new List(captures); + } + + private async Task EnsurePromptCapturePermissionAsync(string promptType) + { + var permissionName = promptType switch + { + AIPromptTypes.AttachmentSummary => AIPermissions.AttachmentSummary.AttachmentSummaryDefault, + AIPromptTypes.ApplicationAnalysis => AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault, + AIPromptTypes.ScoresheetSection => AIPermissions.ScoringAssistant.ScoringAssistantDefault, + _ => null + }; + + if (string.IsNullOrWhiteSpace(permissionName)) + { + throw new UserFriendlyException("Unknown prompt type."); + } + + if (!await permissionChecker.IsGrantedAsync(permissionName)) + { + throw new AbpAuthorizationException("The user doesn't have permission to view prompt capture for this prompt type."); + } + } + + private async Task EnsurePromptCaptureFeatureEnabledAsync(string promptType) + { + var featureName = promptType switch + { + AIPromptTypes.AttachmentSummary => "Unity.AI.AttachmentSummaries", + AIPromptTypes.ApplicationAnalysis => "Unity.AI.ApplicationAnalysis", + AIPromptTypes.ScoresheetSection => "Unity.AI.Scoring", + _ => null + }; + + if (string.IsNullOrWhiteSpace(featureName)) + { + throw new UserFriendlyException("Unknown prompt type."); + } + + if (!await featureChecker.IsEnabledAsync(featureName)) + { + throw new UserFriendlyException("Prompt capture is not enabled for this prompt type."); + } } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs index 577dc6c6f7..a0b00cfd29 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/ApplicationAIScoringAppService.cs @@ -1,23 +1,46 @@ -using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; using System; using System.Threading.Tasks; using Unity.AI.Permissions; using Unity.GrantManager.AI; +using Unity.GrantManager.Applications; +using Unity.GrantManager.Intakes.Events; using Volo.Abp; +using Volo.Abp.EventBus.Local; +using Volo.Abp.Features; namespace Unity.GrantManager.GrantApplications; [Authorize(AIPermissions.ScoringAssistant.ScoringAssistantDefault)] public class ApplicationAIScoringAppService( - IApplicationScoresheetAnalysisService applicationScoresheetAnalysisService) + IApplicationScoresheetAnalysisService applicationScoresheetAnalysisService, + IApplicationRepository applicationRepository, + ILocalEventBus localEventBus, + IFeatureChecker featureChecker) : GrantManagerAppService, IApplicationAIScoringAppService { public async Task GenerateAIScoresheetAnswersAsync(Guid applicationId, string? promptVersion = null, bool capturePromptIo = false) { try { - return await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); + if (!await featureChecker.IsEnabledAsync("Unity.AI.Scoring")) + { + throw new UserFriendlyException("AI scoring is not enabled."); + } + + var result = await applicationScoresheetAnalysisService.RegenerateAndSaveAsync(applicationId, promptVersion, capturePromptIo); + if (string.Equals(result, "{}", StringComparison.Ordinal)) + { + return result; + } + + var application = await applicationRepository.GetAsync(applicationId); + await localEventBus.PublishAsync(new AiScoresheetAnswersGeneratedEvent + { + Application = application + }); + return result; } catch (Exception ex) { 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 ddfa482d1b..598443a95a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/GrantApplications/GrantApplicationAppService.cs @@ -9,20 +9,20 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Unity.Flex.WorksheetInstances; -using Unity.Flex.Worksheets; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.Flex.WorksheetInstances; +using Unity.Flex.Worksheets; using Unity.GrantManager.Applicants; -using Unity.GrantManager.ApplicationForms; -using Unity.GrantManager.Applications; -using Unity.GrantManager.AI; -using Unity.GrantManager.Events; -using Unity.GrantManager.Flex; -using Unity.GrantManager.Identity; -using Unity.GrantManager.Payments; -using Unity.Modules.Shared; +using Unity.GrantManager.ApplicationForms; +using Unity.GrantManager.Applications; +using Unity.GrantManager.AI; +using Unity.GrantManager.Events; +using Unity.GrantManager.Flex; +using Unity.GrantManager.Identity; +using Unity.GrantManager.Payments; +using Unity.Modules.Shared; using Unity.Modules.Shared.Correlation; using Unity.Payments.PaymentRequests; using Volo.Abp; @@ -37,29 +37,29 @@ namespace Unity.GrantManager.GrantApplications; [Authorize] [Dependency(ReplaceServices = true)] [ExposeServices(typeof(GrantApplicationAppService), typeof(IGrantApplicationAppService))] -public class GrantApplicationAppService( +public class GrantApplicationAppService( IApplicationManager applicationManager, IApplicationRepository applicationRepository, IApplicationStatusRepository applicationStatusRepository, IApplicationFormSubmissionRepository applicationFormSubmissionRepository, IApplicantRepository applicantRepository, IApplicationFormRepository applicationFormRepository, - IApplicantAgentRepository applicantAgentRepository, - IApplicantAddressRepository applicantAddressRepository, - IApplicantSupplierAppService applicantSupplierService, - IPaymentRequestAppService paymentRequestService) - : GrantManagerAppService, IGrantApplicationAppService -{ - private static readonly JsonSerializerOptions AiAnalysisReadOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - private static readonly JsonSerializerOptions AiAnalysisWriteOptions = new() - { - WriteIndented = true - }; - + IApplicantAgentRepository applicantAgentRepository, + IApplicantAddressRepository applicantAddressRepository, + IApplicantSupplierAppService applicantSupplierService, + IPaymentRequestAppService paymentRequestService) + : GrantManagerAppService, IGrantApplicationAppService +{ + private static readonly JsonSerializerOptions AiAnalysisReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions AiAnalysisWriteOptions = new() + { + WriteIndented = true + }; + public async Task> GetListAsync(GrantApplicationListInputDto input) { // 1️ Fetch applications with filters + paging in DB @@ -201,9 +201,9 @@ public async Task GetAsync(Guid id) appDto.ContactCellPhone = application.ApplicantAgent.Phone2; } - if (application.Applicant != null) - { - appDto.OrganizationName = application.Applicant.OrgName; + if (application.Applicant != null) + { + appDto.OrganizationName = application.Applicant.OrgName; appDto.OrgNumber = application.Applicant.OrgNumber; appDto.OrganizationSize = application.Applicant.OrganizationSize; appDto.OrgStatus = application.Applicant.OrgStatus; @@ -212,13 +212,13 @@ public async Task GetAsync(Guid id) appDto.Sector = application.Applicant.Sector; appDto.OrganizationType = application.Applicant.OrganizationType; appDto.SubSector = application.Applicant.SubSector; - appDto.SectorSubSectorIndustryDesc = application.Applicant.SectorSubSectorIndustryDesc; - } - - appDto.AIAnalysisData = ParseAiAnalysisData(appDto.AIAnalysis); - - return appDto; - } + appDto.SectorSubSectorIndustryDesc = application.Applicant.SectorSubSectorIndustryDesc; + } + + appDto.AIAnalysisData = ParseAiAnalysisData(appDto.AIAnalysis); + + return appDto; + } public async Task GetApplicationFormAsync(Guid applicationFormId) { @@ -973,11 +973,11 @@ public async Task> GetActions(Guid applicati // NOTE: Authorization is applied on the AppService layer and is false by default // AUTHORIZATION HANDLING - foreach (var item in actionDtos) - { - 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); } @@ -1029,7 +1029,9 @@ from applicant in applicantGroup.DefaultIfEmpty() Id = applications.Id, ProjectName = applications.ProjectName, ReferenceNo = applications.ReferenceNo, - ApplicantName = applicant != null ? (applicant.ApplicantName ?? GrantManagerConsts.UnknownValue) : GrantManagerConsts.UnknownValue + ApplicantName = applicant != null ? (applicant.ApplicantName ?? GrantManagerConsts.UnknownValue) : GrantManagerConsts.UnknownValue, + OrganizationName = applicant != null ? (applicant.OrgName ?? string.Empty) : string.Empty, + UnityApplicantId = applicant != null ? (applicant.UnityApplicantId ?? string.Empty) : string.Empty }; return await query.ToListAsync(); @@ -1053,105 +1055,105 @@ private static Dictionary ExtractCustomFieldsForWorksheet(dynami return result; } - public async Task HideAIAnalysisItemAsync(Guid applicationId, string itemId) - { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: true); - } - - public async Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId) - { - return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: false); - } - - private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applicationId, string itemId, bool isHidden) - { - if (string.IsNullOrWhiteSpace(itemId)) - { - throw new UserFriendlyException("AI analysis item id is required."); - } - - var application = await applicationRepository.GetAsync(applicationId); - - if (string.IsNullOrEmpty(application.AIAnalysis)) + public async Task HideAIAnalysisItemAsync(Guid applicationId, string itemId) + { + return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: true); + } + + public async Task ShowAIAnalysisItemAsync(Guid applicationId, string itemId) + { + return await UpdateAIAnalysisItemVisibilityStateAsync(applicationId, itemId, isHidden: false); + } + + private async Task UpdateAIAnalysisItemVisibilityStateAsync(Guid applicationId, string itemId, bool isHidden) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + throw new UserFriendlyException("AI analysis item id is required."); + } + + var application = await applicationRepository.GetAsync(applicationId); + + if (string.IsNullOrEmpty(application.AIAnalysis)) { throw new UserFriendlyException("No AI analysis available for this application."); - } - - try - { - var updatedAnalysis = SetAnalysisItemHiddenState(application.AIAnalysis, itemId, isHidden); - application.AIAnalysis = updatedAnalysis; - await applicationRepository.UpdateAsync(application); - return updatedAnalysis; - } - catch (Exception ex) - { - var action = isHidden ? "hiding" : "showing"; - var userMessage = isHidden - ? "Failed to hide the AI item. Please try again." - : "Failed to show the AI item. Please try again."; - - Logger.LogError(ex, "Error {Action} AI analysis item {ItemId} for application {ApplicationId}", action, itemId, applicationId); - throw new UserFriendlyException(userMessage); - } - } - - private static string SetAnalysisItemHiddenState(string analysisJson, string itemId, bool isHidden) - { - if (string.IsNullOrWhiteSpace(analysisJson)) - { - return analysisJson; - } - - try - { - var analysis = System.Text.Json.JsonSerializer.Deserialize(analysisJson, AiAnalysisReadOptions); - if (analysis == null) - { - return analysisJson; - } - - UpdateFindingHiddenState(analysis.Errors, itemId, isHidden); - UpdateFindingHiddenState(analysis.Warnings, itemId, isHidden); - UpdateFindingHiddenState(analysis.Summaries, itemId, isHidden); - UpdateFindingHiddenState(analysis.NextSteps, itemId, isHidden); - - return System.Text.Json.JsonSerializer.Serialize(analysis, AiAnalysisWriteOptions); - } - catch (System.Text.Json.JsonException) - { - return analysisJson; - } - } - - private static void UpdateFindingHiddenState(IEnumerable findings, string itemId, bool isHidden) - { - foreach (var finding in findings) - { - if (!string.Equals(finding.Id, itemId, StringComparison.Ordinal)) - { - continue; - } - - finding.Hidden = isHidden; - return; - } - } - - private static ApplicationAnalysisResponse? ParseAiAnalysisData(string? analysisJson) - { - if (string.IsNullOrWhiteSpace(analysisJson)) - { - return null; - } - - try - { - return System.Text.Json.JsonSerializer.Deserialize(analysisJson, AiAnalysisReadOptions); - } - catch (System.Text.Json.JsonException) - { - return null; - } - } -} + } + + try + { + var updatedAnalysis = SetAnalysisItemHiddenState(application.AIAnalysis, itemId, isHidden); + application.AIAnalysis = updatedAnalysis; + await applicationRepository.UpdateAsync(application); + return updatedAnalysis; + } + catch (Exception ex) + { + var action = isHidden ? "hiding" : "showing"; + var userMessage = isHidden + ? "Failed to hide the AI item. Please try again." + : "Failed to show the AI item. Please try again."; + + Logger.LogError(ex, "Error {Action} AI analysis item {ItemId} for application {ApplicationId}", action, itemId, applicationId); + throw new UserFriendlyException(userMessage); + } + } + + private static string SetAnalysisItemHiddenState(string analysisJson, string itemId, bool isHidden) + { + if (string.IsNullOrWhiteSpace(analysisJson)) + { + return analysisJson; + } + + try + { + var analysis = System.Text.Json.JsonSerializer.Deserialize(analysisJson, AiAnalysisReadOptions); + if (analysis == null) + { + return analysisJson; + } + + UpdateFindingHiddenState(analysis.Errors, itemId, isHidden); + UpdateFindingHiddenState(analysis.Warnings, itemId, isHidden); + UpdateFindingHiddenState(analysis.Summaries, itemId, isHidden); + UpdateFindingHiddenState(analysis.NextSteps, itemId, isHidden); + + return System.Text.Json.JsonSerializer.Serialize(analysis, AiAnalysisWriteOptions); + } + catch (System.Text.Json.JsonException) + { + return analysisJson; + } + } + + private static void UpdateFindingHiddenState(IEnumerable findings, string itemId, bool isHidden) + { + foreach (var finding in findings) + { + if (!string.Equals(finding.Id, itemId, StringComparison.Ordinal)) + { + continue; + } + + finding.Hidden = isHidden; + return; + } + } + + private static ApplicationAnalysisResponse? ParseAiAnalysisData(string? analysisJson) + { + if (string.IsNullOrWhiteSpace(analysisJson)) + { + return null; + } + + try + { + return System.Text.Json.JsonSerializer.Deserialize(analysisJson, AiAnalysisReadOptions); + } + catch (System.Text.Json.JsonException) + { + return null; + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs index b06e30aae2..21cc065690 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Intakes/Handlers/GenerateAIContentHandler.cs @@ -23,6 +23,7 @@ public class GenerateAIContentHandler : ILocalEventHandler CreateNewApplicationAsync(IntakeMapping intakeMa CommunityPopulation = MappingUtil.ConvertToIntFromString(intakeMap.CommunityPopulation), RequestedAmount = MappingUtil.ConvertToDecimalFromStringDefaultZero(intakeMap.RequestedAmount), SubmissionDate = MappingUtil.ConvertDateTimeFromStringDefaultNow(intakeMap.SubmissionDate), - ProjectStartDate = MappingUtil.ConvertDateTimeNullableFromString(intakeMap.ProjectStartDate), - ProjectEndDate = MappingUtil.ConvertDateTimeNullableFromString(intakeMap.ProjectEndDate), + ProjectStartDate = MappingUtil.ConvertDateFromChefsFormat(intakeMap.ProjectStartDate), + ProjectEndDate = MappingUtil.ConvertDateFromChefsFormat(intakeMap.ProjectEndDate), TotalProjectBudget = MappingUtil.ConvertToDecimalFromStringDefaultZero(intakeMap.TotalProjectBudget), Community = intakeMap.Community, ElectoralDistrict = intakeMap.ElectoralDistrict, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs index cfd04e2604..7e8ad607fd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Application/Norifications/EmailAppService.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Authorization; +using System; using System.Threading.Tasks; using Unity.Modules.Shared.Utils; +using Unity.Notifications.EmailNotifications; using Unity.Notifications.Emails; using Unity.Notifications.Events; using Volo.Abp.Application.Services; @@ -12,8 +14,12 @@ namespace Unity.GrantManager.Emails [Authorize] [Dependency(ReplaceServices = true)] [ExposeServices(typeof(EmailAppService), typeof(IEmailAppService))] - public class EmailAppService(ILocalEventBus localEventBus) : ApplicationService, IEmailAppService + public class EmailAppService(ILocalEventBus localEventBus, IEmailNotificationService emailNotificationService) : ApplicationService, IEmailAppService { + public async Task InitializeDraftAsync(Guid applicationId) + { + return await emailNotificationService.InitializeDraftAsync(applicationId); + } public async Task CreateAsync(CreateEmailDto dto) { EmailNotificationEvent emailNotificationEvent = GetEmailNotificationEvent(dto); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs index 80d65ae852..dce797827f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Applications/Applicant.cs @@ -32,7 +32,7 @@ public class Applicant : AuditedAggregateRoot, IMultiTenant public virtual Collection? ApplicantAddresses { get; set; } public decimal? MatchPercentage { get; set; } public string? NonRegOrgName { get; set; } - public bool? IsDuplicated { get; set; } + public bool IsDuplicated { get; set; } public string? FundingHistoryComments { get; set; } public string? IssueTrackingComments { get; set; } public string? AuditComments { get; set; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs index 6291c358cb..3958d29de3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDataSeederContributor.cs @@ -4,15 +4,20 @@ using Unity.GrantManager.Assessments; using Unity.GrantManager.GrantApplications; using Unity.GrantManager.Identity; +using Unity.Modules.Shared.Constants; using Volo.Abp.Data; using Volo.Abp.DependencyInjection; using Volo.Abp.Domain.Repositories; +using Volo.Abp.Identity; +using Volo.Abp.MultiTenancy; namespace Unity.GrantManager; public class GrantManagerDataSeederContributor( IApplicationStatusRepository applicationStatusRepository, - IPersonRepository personRepository) : IDataSeedContributor, ITransientDependency + IPersonRepository personRepository, + IIdentityUserRepository userRepository, + ICurrentTenant currentTenant) : IDataSeedContributor, ITransientDependency { public static class GrantApplicationStates { @@ -36,6 +41,7 @@ public async Task SeedAsync(DataSeedContext context) if (context.TenantId == null) // only seed into a tenant database { + await SeedMainBackgroundJobUserAsync(null); return; } @@ -88,4 +94,28 @@ await personRepository.InsertAsync(new Person }); } } + + + private async Task SeedMainBackgroundJobUserAsync(System.Guid? tenantId) + { + using (currentTenant.Change(tenantId)) // Null For Main Unity Grant Manager Context + { + // Check if the IdentityUser already exists + var existingUser = await userRepository.FindAsync(BackgroundJobConstants.BackgroundJobPersonId); + if (existingUser == null) + { + // Create the IdentityUser in the tenant context + await userRepository.InsertAsync( + new IdentityUser( + BackgroundJobConstants.BackgroundJobPersonId, + BackgroundJobConstants.BackgroundJobUserName, + BackgroundJobConstants.BackgroundJobEmail, + null) + { + Name = BackgroundJobConstants.BackgroundJobName + }, + autoSave: true); + } + } + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs index 5a1dd31514..042711194b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/GrantManagerDomainModule.cs @@ -1,5 +1,6 @@ using Unity.GrantManager.MultiTenancy; using Unity.GrantManager.Settings; +using Unity.Modules.Shared.Auditing; using Unity.Notifications; using Volo.Abp.AuditLogging; using Volo.Abp.BackgroundJobs; @@ -19,6 +20,7 @@ namespace Unity.GrantManager; [DependsOn( + typeof(UnityAuditingOverrideModule), typeof(GrantManagerDomainSharedModule), typeof(AbpAuditLoggingDomainModule), typeof(AbpBackgroundJobsDomainModule), diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs index 21dc117b60..66a5b20d2b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Domain/Intakes/Mapping/MappingUtil.cs @@ -84,6 +84,60 @@ public static DateTime ConvertDateTimeFromStringDefaultNow(string? dateTime) return null; } + /// + /// Converts a date string from various CHEFS form component formats to a nullable DateTime. + /// Handles formats from simpledatetime, simpleday, and simpledatetimeadvanced components. + /// Examples: + /// - "2025-06-06T00:00:00-07:00" (simpledatetime/simpledatetimeadvanced - ISO 8601 with timezone) + /// - "06/06/2025" (simpleday - MM/DD/YYYY) + /// - "2025-06-06" (ISO 8601 date only) + /// + /// The date string from a CHEFS form component. + /// A nullable DateTime with the date portion, or null if conversion fails. + public static DateTime? ConvertDateFromChefsFormat(string? dateString) + { + if (string.IsNullOrWhiteSpace(dateString)) + { + return null; + } + + // Try ISO 8601 formats with timezone (simpledatetime, simpledatetimeadvanced) + // Example: "2025-06-06T00:00:00-07:00" + if (DateTime.TryParse(dateString, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out DateTime parsedWithTimezone)) + { + return parsedWithTimezone.Date; + } + + // Try MM/DD/YYYY format (simpleday) + // Example: "06/06/2025" + string[] formats = new[] + { + "MM/dd/yyyy", + "M/d/yyyy", + "MM-dd-yyyy", + "M-d-yyyy" + }; + + if (DateTime.TryParseExact(dateString, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedExact)) + { + return parsedExact.Date; + } + + // Try standard ISO date format (yyyy-MM-dd) + if (DateTime.TryParseExact(dateString, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedIso)) + { + return parsedIso.Date; + } + + // Fallback to general parsing with InvariantCulture + if (DateTime.TryParse(dateString, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime parsedGeneral)) + { + return parsedGeneral.Date; + } + + return null; + } + public static bool IsJObject(dynamic? applicantAgent) { if (applicantAgent == null) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260319211046_MakeIsDuplicatedNonNullable.Designer.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260319211046_MakeIsDuplicatedNonNullable.Designer.cs new file mode 100644 index 0000000000..b7959d11b1 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260319211046_MakeIsDuplicatedNonNullable.Designer.cs @@ -0,0 +1,4791 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Unity.GrantManager.EntityFrameworkCore; +using Volo.Abp.EntityFrameworkCore; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + [DbContext(typeof(GrantTenantDbContext))] + [Migration("20260319211046_MakeIsDuplicatedNonNullable")] + partial class MakeIsDuplicatedNonNullable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("_Abp_DatabaseProvider", EfCoreDatabaseProvider.PostgreSql) + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("ScoresheetInstanceId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId"); + + b.HasIndex("ScoresheetInstanceId"); + + b.ToTable("Answers", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("Questions", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Scoresheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ScoresheetId"); + + b.ToTable("ScoresheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CustomFieldId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetInstanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetInstanceId"); + + b.ToTable("CustomFieldValues", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("CurrentValue") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetCorrelationId") + .HasColumnType("uuid"); + + b.Property("WorksheetCorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("WorksheetInstances", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UiAnchor") + .IsRequired() + .HasColumnType("text"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetLinks", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Label") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("SectionId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SectionId"); + + b.ToTable("CustomFields", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("Worksheets", "Flex"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("WorksheetId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorksheetId"); + + b.ToTable("WorksheetSections", "Flex"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("ApproxNumberOfEmployees") + .HasColumnType("text"); + + b.Property("AuditComments") + .HasColumnType("text"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FiscalDay") + .HasColumnType("integer"); + + b.Property("FiscalMonth") + .HasColumnType("text"); + + b.Property("FundingHistoryComments") + .HasColumnType("text"); + + b.Property("IndigenousOrgInd") + .HasColumnType("text"); + + b.Property("IsDuplicated") + .HasColumnType("boolean"); + + b.Property("IssueTrackingComments") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MatchPercentage") + .HasColumnType("numeric"); + + b.Property("NonRegOrgName") + .HasColumnType("text"); + + b.Property("NonRegisteredBusinessName") + .HasColumnType("text"); + + b.Property("OrgName") + .HasColumnType("text"); + + b.Property("OrgNumber") + .HasColumnType("text"); + + b.Property("OrgStatus") + .HasColumnType("text"); + + b.Property("OrganizationSize") + .HasColumnType("text"); + + b.Property("OrganizationType") + .HasColumnType("text"); + + b.Property("RedStop") + .HasColumnType("boolean"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("SectorSubSectorIndustryDesc") + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("StartedOperatingDate") + .HasColumnType("date"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SubSector") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UnityApplicantId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantName"); + + b.ToTable("Applicants", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddressType") + .HasColumnType("integer"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Postal") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("Street") + .HasColumnType("text"); + + b.Property("Street2") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Unit") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicantAddresses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("BceidBusinessGuid") + .HasColumnType("uuid"); + + b.Property("BceidBusinessName") + .HasColumnType("text"); + + b.Property("BceidUserGuid") + .HasColumnType("uuid"); + + b.Property("BceidUserName") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactOrder") + .HasColumnType("integer"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IdentityEmail") + .HasColumnType("text"); + + b.Property("IdentityName") + .HasColumnType("text"); + + b.Property("IdentityProvider") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsConfirmed") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSubUser") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.Property("Phone2") + .HasColumnType("text"); + + b.Property("Phone2Extension") + .HasColumnType("text"); + + b.Property("PhoneExtension") + .HasColumnType("text"); + + b.Property("RoleForApplicant") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationId") + .IsUnique(); + + b.ToTable("ApplicantAgents", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AIAnalysis") + .HasColumnType("text"); + + b.Property("AIScoresheetAnswers") + .HasColumnType("jsonb"); + + b.Property("Acquisition") + .HasColumnType("text"); + + b.Property("ApplicantElectoralDistrict") + .HasColumnType("text"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationStatusId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("AssessmentResultDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AssessmentResultStatus") + .HasColumnType("text"); + + b.Property("AssessmentStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Community") + .HasColumnType("text"); + + b.Property("CommunityPopulation") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractExecutionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ContractNumber") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeclineRational") + .HasColumnType("text"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("DueDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DueDiligenceStatus") + .HasColumnType("text"); + + b.Property("EconomicRegion") + .HasColumnType("text"); + + b.Property("ElectoralDistrict") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinalDecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("Forestry") + .HasColumnType("text"); + + b.Property("ForestryFocus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LikelihoodOfFunding") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("NotificationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("OwnerId") + .HasColumnType("uuid"); + + b.Property("Payload") + .HasColumnType("jsonb"); + + b.Property("PercentageTotalProjectBudget") + .HasColumnType("double precision"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("ProjectEndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectFundingTotal") + .HasColumnType("numeric"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ProjectStartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ProjectSummary") + .HasColumnType("text"); + + b.Property("ProposalDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RecommendedAmount") + .HasColumnType("numeric"); + + b.Property("ReferenceNo") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionalDistrict") + .HasColumnType("text"); + + b.Property("RequestedAmount") + .HasColumnType("numeric"); + + b.Property("RiskRanking") + .HasColumnType("text"); + + b.Property("SigningAuthorityBusinessPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityCellPhone") + .HasColumnType("text"); + + b.Property("SigningAuthorityEmail") + .HasColumnType("text"); + + b.Property("SigningAuthorityFullName") + .HasColumnType("text"); + + b.Property("SigningAuthorityTitle") + .HasColumnType("text"); + + b.Property("SubStatus") + .HasColumnType("text"); + + b.Property("SubmissionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalProjectBudget") + .HasColumnType("numeric"); + + b.Property("TotalScore") + .HasColumnType("integer"); + + b.Property("UnityApplicationId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.HasIndex("ApplicationStatusId"); + + b.HasIndex("OwnerId"); + + b.ToTable("Applications", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Duty") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssigneeId"); + + b.ToTable("ApplicationAssignments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AISummary") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsFileId") + .HasColumnType("text"); + + b.Property("ChefsSubmissionId") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationChefsFileAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("ContactFullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactMobilePhone") + .HasColumnType("text"); + + b.Property("ContactTitle") + .HasColumnType("text"); + + b.Property("ContactType") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContactWorkPhone") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationContact", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .HasColumnType("text"); + + b.Property("ApplicationFormDescription") + .HasColumnType("text"); + + b.Property("ApplicationFormName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("AttemptedConnectionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsCriteriaFormGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ConnectionHttpStatus") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultPaymentGroup") + .HasColumnType("integer"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ElectoralDistrictAddressType") + .HasColumnType("integer"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormHierarchy") + .HasColumnType("integer"); + + b.Property("IntakeId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsDirectApproval") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ParentFormId") + .HasColumnType("uuid"); + + b.Property("Payable") + .HasColumnType("boolean"); + + b.Property("PaymentApprovalThreshold") + .HasColumnType("numeric"); + + b.Property("Prefix") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreventPayment") + .HasColumnType("boolean"); + + b.Property("RenderFormIoToHtml") + .HasColumnType("boolean"); + + b.Property("ScoresheetId") + .HasColumnType("uuid"); + + b.Property("SuffixType") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IntakeId"); + + b.HasIndex("ParentFormId"); + + b.ToTable("ApplicationForms", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("ApplicationFormVersionId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ChefsSubmissionGuid") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormVersionId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("RenderedHTML") + .HasColumnType("text"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Submission") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormSubmissions", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationFormId") + .HasColumnType("uuid"); + + b.Property("AvailableChefsFields") + .HasColumnType("text"); + + b.Property("ChefsApplicationFormGuid") + .HasColumnType("text"); + + b.Property("ChefsFormVersionGuid") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FormSchema") + .HasColumnType("jsonb"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Published") + .HasColumnType("boolean"); + + b.Property("ReportColumns") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportKeys") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReportViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmissionHeaderMapping") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationFormId"); + + b.ToTable("ApplicationFormVersion", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LinkType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Related"); + + b.Property("LinkedApplicationId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("ApplicationLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StatusCode") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("StatusCode") + .IsUnique(); + + b.ToTable("ApplicationStatuses", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("TagId"); + + b.ToTable("ApplicationTags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.ToTable("AssessmentAttachments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("AuditDate") + .HasColumnType("timestamp without time zone"); + + b.Property("AuditNote") + .HasColumnType("text"); + + b.Property("AuditTrackingNumber") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("AuditHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApprovedAmount") + .HasColumnType("numeric"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FundingNotes") + .HasColumnType("text"); + + b.Property("FundingYear") + .HasColumnType("integer"); + + b.Property("GrantCategory") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ReconsiderationAmount") + .HasColumnType("numeric"); + + b.Property("RenewedFunding") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TotalGrantAmount") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("FundingHistories", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IssueDescription") + .HasColumnType("text"); + + b.Property("IssueHeading") + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("ResolutionNote") + .HasColumnType("text"); + + b.Property("Resolved") + .HasColumnType("boolean"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantId"); + + b.ToTable("IssueTrackings", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("ApprovalRecommended") + .HasColumnType("boolean"); + + b.Property("AssessorId") + .HasColumnType("uuid"); + + b.Property("CleanGrowth") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("EconomicImpact") + .HasColumnType("integer"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FinancialAnalysis") + .HasColumnType("integer"); + + b.Property("InclusiveGrowth") + .HasColumnType("integer"); + + b.Property("IsAiAssessment") + .HasColumnType("boolean"); + + b.Property("IsComplete") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AssessorId"); + + b.ToTable("Assessments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("CommenterId"); + + b.ToTable("ApplicationComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommenterId") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PinDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AssessmentId"); + + b.HasIndex("CommenterId"); + + b.ToTable("AssessmentComments", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.Contact", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("HomePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MobilePhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("WorkPhoneExtension") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("WorkPhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Contacts", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("RelatedEntityId") + .HasColumnType("uuid"); + + b.Property("RelatedEntityType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("RelatedEntityType", "RelatedEntityId"); + + b.HasIndex("ContactId", "RelatedEntityType", "RelatedEntityId"); + + b.ToTable("ContactLinks", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.GlobalTag.Tag", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Tags", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Identity.Person", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Badge") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("OidcDisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("OidcSub") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("OidcSub"); + + b.ToTable("Persons", (string)null); + }); + + modelBuilder.Entity("Unity.GrantManager.Intakes.Intake", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Budget") + .HasColumnType("double precision"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EndDate") + .HasColumnType("timestamp without time zone"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IntakeName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("StartDate") + .HasColumnType("timestamp without time zone"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Intakes", (string)null); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("EmailGroupUsers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApplicantId") + .HasColumnType("uuid"); + + b.Property("ApplicationId") + .HasColumnType("uuid"); + + b.Property("AssessmentId") + .HasColumnType("uuid"); + + b.Property("BCC") + .IsRequired() + .HasColumnType("text"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CC") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesHttpStatusCode") + .HasColumnType("text"); + + b.Property("ChesMsgId") + .HasColumnType("uuid"); + + b.Property("ChesResponse") + .IsRequired() + .HasColumnType("text"); + + b.Property("ChesStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FromAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestIds") + .IsRequired() + .HasColumnType("text"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("text"); + + b.Property("RetryAttempts") + .HasColumnType("integer"); + + b.Property("SendOnDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("SentDateTime") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tag") + .IsRequired() + .HasColumnType("text"); + + b.Property("TemplateName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ToAddress") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("EmailLogs", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DisplayName") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("EmailLogId") + .HasColumnType("uuid"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("S3ObjectKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Time") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailLogId"); + + b.HasIndex("S3ObjectKey"); + + b.ToTable("EmailLogAttachments", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.EmailTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyHTML") + .IsRequired() + .HasColumnType("text"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SendFrom") + .IsRequired() + .HasColumnType("text"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("EmailTemplates", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Subscriber", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Subscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionGroups", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("SubscriberId"); + + b.ToTable("SubscriptionGroupSubscribers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TemplateVariable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MapTo") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TemplateVariables", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.Trigger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("InternalName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Triggers", "Notifications"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("SubscriptionGroupId") + .HasColumnType("uuid"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("TriggerId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionGroupId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("TriggerId"); + + b.ToTable("TriggerSubscriptions", "Notifications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.AccountCodings.AccountCoding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("Description") + .HasMaxLength(35) + .HasColumnType("character varying(35)"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("MinistryClient") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("Responsibility") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServiceLine") + .IsRequired() + .HasColumnType("text"); + + b.Property("Stob") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("AccountCodings", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentConfigurations.PaymentConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DefaultAccountCodingId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentIdPrefix") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("PaymentConfigurations", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DecisionDate") + .HasColumnType("timestamp without time zone"); + + b.Property("DecisionUserId") + .HasColumnType("uuid"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.ToTable("ExpenseApprovals", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountCodingId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("BatchName") + .IsRequired() + .HasColumnType("text"); + + b.Property("BatchNumber") + .HasColumnType("numeric"); + + b.Property("CasHttpStatusCode") + .HasColumnType("integer"); + + b.Property("CasResponse") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("ContractNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("FsbApNotified") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("FsbNotificationEmailLogId") + .HasColumnType("uuid"); + + b.Property("FsbNotificationSentDate") + .HasColumnType("timestamp without time zone"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("InvoiceStatus") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("IsRecon") + .HasColumnType("boolean"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("PayeeName") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentDate") + .HasColumnType("text"); + + b.Property("PaymentNumber") + .HasColumnType("text"); + + b.Property("PaymentStatus") + .HasColumnType("text"); + + b.Property("ReferenceNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequesterName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SubmissionConfirmationCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("SupplierName") + .HasColumnType("text"); + + b.Property("SupplierNumber") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("AccountCodingId"); + + b.HasIndex("FsbNotificationEmailLogId"); + + b.HasIndex("ReferenceNumber") + .IsUnique(); + + b.HasIndex("SiteId"); + + b.ToTable("PaymentRequests", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("PaymentRequestId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("PaymentRequestId"); + + b.HasIndex("TagId"); + + b.ToTable("PaymentTags", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentThresholds.PaymentThreshold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("Threshold") + .HasColumnType("numeric"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("PaymentThresholds", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddressLine1") + .HasColumnType("text"); + + b.Property("AddressLine2") + .HasColumnType("text"); + + b.Property("AddressLine3") + .HasColumnType("text"); + + b.Property("BankAccount") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("EFTAdvicePref") + .HasColumnType("text"); + + b.Property("EmailAddress") + .HasColumnType("text"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCas") + .HasColumnType("timestamp without time zone"); + + b.Property("MarkDeletedInUse") + .HasColumnType("boolean"); + + b.Property("Number") + .IsRequired() + .HasColumnType("text"); + + b.Property("PaymentGroup") + .HasColumnType("integer"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SiteProtected") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("SupplierId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.HasIndex("SupplierId"); + + b.ToTable("Sites", "Payments"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BusinessNumber") + .HasColumnType("text"); + + b.Property("City") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("ConcurrencyStamp"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("DeleterId") + .HasColumnType("uuid") + .HasColumnName("DeleterId"); + + b.Property("DeletionTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("DeletionTime"); + + b.Property("ExtraProperties") + .IsRequired() + .HasColumnType("text") + .HasColumnName("ExtraProperties"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("LastUpdatedInCAS") + .HasColumnType("timestamp without time zone"); + + b.Property("MailingAddress") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("text"); + + b.Property("PostalCode") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("Province") + .HasColumnType("text"); + + b.Property("SIN") + .HasColumnType("text"); + + b.Property("StandardIndustryClassification") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("text"); + + b.Property("Subcategory") + .HasColumnType("text"); + + b.Property("SupplierProtected") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.HasKey("Id"); + + b.ToTable("Suppliers", "Payments"); + }); + + modelBuilder.Entity("Unity.Reporting.Domain.Configuration.ReportColumnsMap", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("CorrelationProvider") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("CreationTime"); + + b.Property("CreatorId") + .HasColumnType("uuid") + .HasColumnName("CreatorId"); + + b.Property("LastModificationTime") + .HasColumnType("timestamp without time zone") + .HasColumnName("LastModificationTime"); + + b.Property("LastModifierId") + .HasColumnType("uuid") + .HasColumnName("LastModifierId"); + + b.Property("Mapping") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoleStatus") + .HasColumnType("integer"); + + b.Property("TenantId") + .HasColumnType("uuid") + .HasColumnName("TenantId"); + + b.Property("ViewName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ViewStatus") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ReportColumnsMaps", "Reporting"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Instances") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Answer", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Question", "Question") + .WithMany("Answers") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", null) + .WithMany("Answers") + .HasForeignKey("ScoresheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.ScoresheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Scoresheets.Scoresheet", "Scoresheet") + .WithMany("Sections") + .HasForeignKey("ScoresheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scoresheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.CustomFieldValue", b => + { + b.HasOne("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", null) + .WithMany("Values") + .HasForeignKey("WorksheetInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetLinks.WorksheetLink", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Links") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.CustomField", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.WorksheetSection", "Section") + .WithMany("Fields") + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Section"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.HasOne("Unity.Flex.Domain.Worksheets.Worksheet", "Worksheet") + .WithMany("Sections") + .HasForeignKey("WorksheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Worksheet"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAddress", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicantAddresses") + .HasForeignKey("ApplicationId"); + + b.Navigation("Applicant"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicantAgent", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithOne("ApplicantAgent") + .HasForeignKey("Unity.GrantManager.Applications.ApplicantAgent", "ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", "Applicant") + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", "ApplicationForm") + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationStatus", "ApplicationStatus") + .WithMany("Applications") + .HasForeignKey("ApplicationStatusId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Applicant"); + + b.Navigation("ApplicationForm"); + + b.Navigation("ApplicationStatus"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAssignment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationAssignments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", "Assignee") + .WithMany() + .HasForeignKey("AssigneeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Assignee"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationChefsFileAttachment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationContact", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationForm", b => + { + b.HasOne("Unity.GrantManager.Intakes.Intake", null) + .WithMany() + .HasForeignKey("IntakeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ParentFormId") + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormSubmission", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationFormVersion", b => + { + b.HasOne("Unity.GrantManager.Applications.ApplicationForm", null) + .WithMany() + .HasForeignKey("ApplicationFormId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationLink", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany("ApplicationLinks") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationTags", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("ApplicationTags") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AssessmentAttachment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.AuditHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.FundingHistory", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.IssueTracking", b => + { + b.HasOne("Unity.GrantManager.Applications.Applicant", null) + .WithMany() + .HasForeignKey("ApplicantId"); + }); + + modelBuilder.Entity("Unity.GrantManager.Assessments.Assessment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", "Application") + .WithMany("Assessments") + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("AssessorId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.ApplicationComment", b => + { + b.HasOne("Unity.GrantManager.Applications.Application", null) + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Comments.AssessmentComment", b => + { + b.HasOne("Unity.GrantManager.Assessments.Assessment", null) + .WithMany() + .HasForeignKey("AssessmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.Identity.Person", null) + .WithMany() + .HasForeignKey("CommenterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.GrantManager.Contacts.ContactLink", b => + { + b.HasOne("Unity.GrantManager.Contacts.Contact", null) + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.EmailGroups.EmailGroupUser", b => + { + b.HasOne("Unity.Notifications.EmailGroups.EmailGroup", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Emails.EmailLogAttachment", b => + { + b.HasOne("Unity.Notifications.Emails.EmailLog", null) + .WithMany() + .HasForeignKey("EmailLogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.SubscriptionGroupSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("GroupId"); + + b.HasOne("Unity.Notifications.Templates.Subscriber", "Subscriber") + .WithMany() + .HasForeignKey("SubscriberId"); + + b.Navigation("Subscriber"); + + b.Navigation("SubscriptionGroup"); + }); + + modelBuilder.Entity("Unity.Notifications.Templates.TriggerSubscription", b => + { + b.HasOne("Unity.Notifications.Templates.SubscriptionGroup", "SubscriptionGroup") + .WithMany() + .HasForeignKey("SubscriptionGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.EmailTemplate", "EmailTemplate") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.Notifications.Templates.Trigger", "Trigger") + .WithMany() + .HasForeignKey("TriggerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EmailTemplate"); + + b.Navigation("SubscriptionGroup"); + + b.Navigation("Trigger"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.ExpenseApproval", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", "PaymentRequest") + .WithMany("ExpenseApprovals") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PaymentRequest"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.HasOne("Unity.Payments.Domain.AccountCodings.AccountCoding", "AccountCoding") + .WithMany() + .HasForeignKey("AccountCodingId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Unity.Payments.Domain.Suppliers.Site", "Site") + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("AccountCoding"); + + b.Navigation("Site"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentTags.PaymentTag", b => + { + b.HasOne("Unity.Payments.Domain.PaymentRequests.PaymentRequest", null) + .WithMany("PaymentTags") + .HasForeignKey("PaymentRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Unity.GrantManager.GlobalTag.Tag", "Tag") + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Site", b => + { + b.HasOne("Unity.Payments.Domain.Suppliers.Supplier", "Supplier") + .WithMany("Sites") + .HasForeignKey("SupplierId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Supplier"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.ScoresheetInstances.ScoresheetInstance", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Question", b => + { + b.Navigation("Answers"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.Scoresheet", b => + { + b.Navigation("Instances"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Scoresheets.ScoresheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.WorksheetInstances.WorksheetInstance", b => + { + b.Navigation("Values"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.Worksheet", b => + { + b.Navigation("Links"); + + b.Navigation("Sections"); + }); + + modelBuilder.Entity("Unity.Flex.Domain.Worksheets.WorksheetSection", b => + { + b.Navigation("Fields"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Applicant", b => + { + b.Navigation("ApplicantAddresses"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.Application", b => + { + b.Navigation("ApplicantAddresses"); + + b.Navigation("ApplicantAgent"); + + b.Navigation("ApplicationAssignments"); + + b.Navigation("ApplicationLinks"); + + b.Navigation("ApplicationTags"); + + b.Navigation("Assessments"); + }); + + modelBuilder.Entity("Unity.GrantManager.Applications.ApplicationStatus", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.PaymentRequests.PaymentRequest", b => + { + b.Navigation("ExpenseApprovals"); + + b.Navigation("PaymentTags"); + }); + + modelBuilder.Entity("Unity.Payments.Domain.Suppliers.Supplier", b => + { + b.Navigation("Sites"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260319211046_MakeIsDuplicatedNonNullable.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260319211046_MakeIsDuplicatedNonNullable.cs new file mode 100644 index 0000000000..fb00b5820b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/20260319211046_MakeIsDuplicatedNonNullable.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Unity.GrantManager.Migrations.TenantMigrations +{ + /// + public partial class MakeIsDuplicatedNonNullable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + "UPDATE \"Applicants\" SET \"IsDuplicated\" = false WHERE \"IsDuplicated\" IS NULL;"); + + migrationBuilder.AlterColumn( + name: "IsDuplicated", + table: "Applicants", + type: "boolean", + nullable: false, + defaultValue: false, + oldClrType: typeof(bool), + oldType: "boolean", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "IsDuplicated", + table: "Applicants", + type: "boolean", + nullable: true, + oldClrType: typeof(bool), + oldType: "boolean"); + } + } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs index 491ceaa2c5..8f19cf1aab 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Migrations/TenantMigrations/GrantTenantDbContextModelSnapshot.cs @@ -836,7 +836,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IndigenousOrgInd") .HasColumnType("text"); - b.Property("IsDuplicated") + b.Property("IsDuplicated") .HasColumnType("boolean"); b.Property("IssueTrackingComments") diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs index f6da07f3f9..7d9d7850e0 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/ApplicantRepository.cs @@ -104,7 +104,8 @@ public async Task GetApplicantAutocompleteQueryAsync(string? appli a.SectorSubSectorIndustryDesc, a.FiscalDay, a.FiscalMonth, - a.UnityApplicantId + a.UnityApplicantId, + a.IsDuplicated }) .Take(10) .ToList(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs index a9d3225bce..2a95ae26c9 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.EntityFrameworkCore/Repositories/EfCoreAuditLogRepository.cs @@ -1,15 +1,16 @@ +using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; -using System.Threading.Tasks; +using System.Linq; using System.Threading; -using Volo.Abp.EntityFrameworkCore; +using System.Threading.Tasks; using Unity.GrantManager.Applications; +using Unity.Modules.Shared.Constants; using Volo.Abp.AuditLogging; using Volo.Abp.AuditLogging.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore; -using System.Linq; -using Volo.Abp.Identity; using Volo.Abp.DependencyInjection; +using Volo.Abp.EntityFrameworkCore; +using Volo.Abp.Identity; namespace Unity.GrantManager.Repositories { @@ -53,7 +54,12 @@ public virtual async Task> GetEntityChangeByTypeW private async Task ResolveUsername(Guid userId) { - var user = await identityUserRepository.GetAsync(userId); + if(userId == BackgroundJobConstants.BackgroundJobPersonId) + { + return $"{BackgroundJobConstants.BackgroundJobName}"; + } + + var user = await identityUserRepository.GetAsync(userId); return user != null ? $"{user.Name} {user.Surname}" : string.Empty; } } 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 7a957daef7..af8cbd315a 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 @@ -44,6 +44,7 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs index 4941e3c31f..42f004ef31 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.HttpApi/Controllers/AttachmentController.cs @@ -10,7 +10,9 @@ using System.Threading.Tasks; using Unity.GrantManager.Attachments; using Unity.GrantManager.Intakes; +using Unity.Notifications.Emails; using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.MultiTenancy; using Volo.Abp.Validation; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -24,17 +26,26 @@ public class AttachmentController : AbpController private readonly IFileAppService _fileAppService; private readonly IConfiguration _configuration; private readonly ISubmissionAppService _submissionAppService; + private readonly IEmailLogAttachmentUploadService _emailLogAttachmentUploadService; + private readonly ICurrentTenant _currentTenant; private ILogger logger => LazyServiceProvider.LazyGetService(provider => LoggerFactory?.CreateLogger(GetType().FullName!) ?? NullLogger.Instance); private const string badRequestFileMsg = "File name must be provided."; private const string NotFoundFileMsg = "File not found."; private const string errorFileMsg = "An error occurred while downloading the file."; private const string chefsApiAccessError = "You do not have access to this resource"; - public AttachmentController(IFileAppService fileAppService, IConfiguration configuration, ISubmissionAppService submissionAppService) + public AttachmentController( + IFileAppService fileAppService, + IConfiguration configuration, + ISubmissionAppService submissionAppService, + IEmailLogAttachmentUploadService emailLogAttachmentUploadService, + ICurrentTenant currentTenant) { _fileAppService = fileAppService; _configuration = configuration; _submissionAppService = submissionAppService; + _emailLogAttachmentUploadService = emailLogAttachmentUploadService; + _currentTenant = currentTenant; } [HttpGet("application/{applicationId}/download/{fileName}")] @@ -250,6 +261,50 @@ public async Task UploadApplicationAttachments(Guid applicationId return await UploadFiles(files); } + [HttpPost("email/{emailLogId}/upload")] + public async Task UploadEmailAttachments(Guid emailLogId, IList files) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + if (files == null || files.Count == 0) + { + return BadRequest("At least one file must be provided."); + } + + List invalidFileTypes = GetInvalidFileTypes(files); + if (invalidFileTypes.Count > 0) + { + throw new AbpValidationException(message: "ERROR: Invalid File Type.", validationErrors: invalidFileTypes); + } + + var results = new List(); + foreach (var file in files) + { + try + { + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var dto = await _emailLogAttachmentUploadService.UploadAsync( + emailLogId, + _currentTenant.Id, + file.FileName, + ms.ToArray(), + file.ContentType ?? "application/octet-stream"); + results.Add(dto); + } + catch (Exception ex) + { + logger.LogError(ex, "AttachmentController->UploadEmailAttachments: Failed to upload {FileName}", file.FileName); + return StatusCode(500, $"Failed to upload {file.FileName}: {ex.Message}"); + } + } + + return Ok(results); + } + private async Task UploadFiles(IList files) { List InvalidFileTypes = GetInvalidFileTypes(files); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs index 650eee29ee..7e9f1a5620 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/AI/AIPromptToolViewOptionsProvider.cs @@ -12,9 +12,20 @@ public class AIPromptToolViewOptionsProvider( public bool IsDevPromptControlsEnabled => string.Equals(webHostEnvironment.EnvironmentName, "Development", StringComparison.OrdinalIgnoreCase); - public string DefaultPromptVersion => - string.IsNullOrWhiteSpace(configuration["Azure:OpenAI:PromptVersion"]) - ? "v1" - : configuration["Azure:OpenAI:PromptVersion"]!.Trim().ToLowerInvariant(); + public string DefaultPromptVersion + { + get + { + var configuredPromptVersion = configuration["Azure:Operations:Defaults:PromptVersion"]; + if (string.IsNullOrWhiteSpace(configuredPromptVersion)) + { + configuredPromptVersion = configuration["Azure:OpenAI:PromptVersion"]; + } + + return string.IsNullOrWhiteSpace(configuredPromptVersion) + ? "v1" + : configuredPromptVersion.Trim().ToLowerInvariant(); + } + } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs index 826590b751..76b9d46596 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/GrantManagerWebModule.cs @@ -171,6 +171,9 @@ public override void ConfigureServices(ServiceConfigurationContext context) ) ); + options.IsEnabledForAnonymousUsers = true; + options.IsEnabledForIntegrationServices = true; // Enable auditing for background jobs and message consumers + options.EntityHistorySelectors.Add( new NamedTypeSelector( "ExplictEntityAudit", @@ -179,7 +182,8 @@ public override void ConfigureServices(ServiceConfigurationContext context) if (type.Name.Contains("Role", StringComparison.OrdinalIgnoreCase) || type.Name.Contains("User", StringComparison.OrdinalIgnoreCase) - || type.Name.Contains("Permission", StringComparison.OrdinalIgnoreCase)) + || type.Name.Contains("Permission", StringComparison.OrdinalIgnoreCase) + || type.Name.Contains("Payment", StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js index 68a1855b1f..6a1ce0db94 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/Applicants/Details.js @@ -3,12 +3,12 @@ $(document).ready(function () { initializeApplicantDetailsPage(); scheduleInitialLayoutPasses(); - window.addEventListener('applicant-submissions-layout-changed', function () { + globalThis.addEventListener('applicant-submissions-layout-changed', function () { applyTabHeightOffset(); debouncedResizeAwareDataTables(); scheduleDeferredLayoutPass(); }); - window.addEventListener('applicant-addresses-layout-changed', function () { + globalThis.addEventListener('applicant-addresses-layout-changed', function () { applyTabHeightOffset(); debouncedResizeAwareDataTables(); scheduleDeferredLayoutPass(); @@ -16,7 +16,7 @@ $(document).ready(function () { // Handle breadcrumb back button $('#goBackToApplicants').on('click', function () { - window.location.href = '/GrantApplicants'; + globalThis.location.href = '/GrantApplicants'; }); // Handle tab switching animations @@ -60,7 +60,7 @@ function initializeApplicantDetailsPage() { }, 500); // Initialize tooltips if any - let tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); + let tooltipTriggerList = Array.prototype.slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl); }); @@ -70,9 +70,8 @@ function initializeApplicantDetailsPage() { function debounce(func, wait) { let timeout; return function (...args) { - const context = this; clearTimeout(timeout); - timeout = setTimeout(() => func.apply(context, args), wait); + timeout = setTimeout(() => func.apply(this, args), wait); }; } @@ -295,7 +294,7 @@ function getAvailableViewportHeight(element, minHeight) { const bottomSpacing = 12; return Math.max( minHeight, - Math.floor(window.innerHeight - element.getBoundingClientRect().top - bottomSpacing) + Math.floor(globalThis.innerHeight - element.getBoundingClientRect().top - bottomSpacing) ); } @@ -346,8 +345,8 @@ function initializeResizableDivider() { }; const restoreDividerPosition = () => { - const savedPercentage = parseFloat(localStorage.getItem(storageKey)); - if (isNaN(savedPercentage)) { + const savedPercentage = Number.parseFloat(localStorage.getItem(storageKey)); + if (Number.isNaN(savedPercentage)) { return; } @@ -367,8 +366,8 @@ function initializeResizableDivider() { document.body.style.cursor = 'col-resize'; }); - window.addEventListener('resize', restoreDividerPosition); - window.addEventListener('resize', applyTabHeightOffset); + globalThis.addEventListener('resize', restoreDividerPosition); + globalThis.addEventListener('resize', applyTabHeightOffset); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml index d80a621884..4757e953d4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/ApplicationLinks/ApplicationLinksModal.cshtml @@ -36,7 +36,7 @@
- +
@@ -54,8 +54,8 @@
- - 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 860a412d4f..f52738140b 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 @@ -26,14 +26,18 @@ @inject ICurrentTenant CurrentTenant @inject ISettingProvider SettingProvider -@{ - PageLayout.Content.Title = L["Grants"].Value; - 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.ApplicationAnalysis.ApplicationAnalysisDefault); - var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); -} +@{ + PageLayout.Content.Title = L["Grants"].Value; + var notificationsFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Notifications"); + var readEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email"); + var aiAttachmentSummariesEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.AttachmentSummaries") + && await PermissionChecker.IsGrantedAsync(AIPermissions.AttachmentSummary.AttachmentSummaryDefault); + var aiApplicationAnalysisEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.ApplicationAnalysis") + && await PermissionChecker.IsGrantedAsync(AIPermissions.ApplicationAnalysis.ApplicationAnalysisDefault); + var aiScoringEnabled = await FeatureChecker.IsEnabledAsync("Unity.AI.Scoring") + && await PermissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault); + var flexFeatureEnabled = await FeatureChecker.IsEnabledAsync("Unity.Flex"); +} @section styles { @@ -67,6 +71,7 @@ + @functions { @@ -493,12 +498,15 @@
Attachment
- + @if (aiAttachmentSummariesEnabled) + { + + }
@@ -533,12 +541,15 @@
Scoring
- + @if (aiScoringEnabled) + { + + }
diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js index 2e78d0df13..8b3542b988 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/Details.js @@ -16,35 +16,6 @@ $(function () { setPromptCaptureOutput(outputSelector, ''); }; - function formatAIPromptCaptureBlock(capture) { - const parts = []; - - parts.push(`PROMPT TYPE: ${capture.promptType || ''}`); - parts.push(`PROMPT VERSION: ${capture.promptVersion || ''}`); - - if (capture.captureLabel) { - parts.push(`LABEL: ${capture.captureLabel}`); - } - - if (capture.capturedAt) { - parts.push(`CAPTURED AT: ${capture.capturedAt}`); - } - - parts.push(''); - parts.push('SYSTEM PROMPT'); - parts.push(capture.systemPrompt || ''); - parts.push(''); - parts.push('USER PROMPT'); - parts.push(capture.userPrompt || ''); - parts.push(''); - parts.push('RAW OUTPUT'); - parts.push(capture.rawOutput || ''); - parts.push(''); - parts.push('FORMATTED OUTPUT'); - parts.push(capture.formattedOutput || ''); - - return parts.join('\n'); - } globalThis.renderAIPromptCapture = function(containerSelector, outputSelector, captures) { if (!Array.isArray(captures) || captures.length === 0) { @@ -913,6 +884,36 @@ $(function () { globalThis.addEventListener('resize', windowResize); }); +function formatAIPromptCaptureBlock(capture) { + const output = capture.output || ''; + const parts = [ + `PROMPT TYPE: ${capture.promptType || ''}`, + `PROMPT VERSION: ${capture.promptVersion || ''}` + ]; + + if (capture.captureLabel) { + parts.push(`LABEL: ${capture.captureLabel}`); + } + + if (capture.capturedAt) { + parts.push(`CAPTURED AT: ${capture.capturedAt}`); + } + + parts.push( + '', + 'SYSTEM PROMPT', + capture.systemPrompt || '', + '', + 'USER PROMPT', + capture.userPrompt || '', + '', + 'OUTPUT', + output + ); + + return parts.join('\n'); +} + // Handle the card header click event function onCardHeaderClick(clickedHeader, cardHeaders) { @@ -1375,3 +1376,4 @@ function clearCurrencyError(input) { document.getElementById(errorSpan).textContent = ''; input.attr('aria-invalid', 'false'); } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js index 3c16d8b86a..26f9c97ac2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Pages/GrantApplications/ai-analysis.js @@ -79,7 +79,7 @@ function normalizeFindings(items, fallbackType) { }; return (items || []) - .filter(item => item) + .filter(Boolean) .map((item, index) => ({ ...item, id: item.id || `${fallbackType}-${index}`, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js index 8e0c4348c4..62815b2aae 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantAddresses/Default.js @@ -10,9 +10,6 @@ $(function () { let addressesTable = null; let zoneForm = null; - function notifyApplicantAddressesLayoutChange() { - window.dispatchEvent(new CustomEvent('applicant-addresses-layout-changed')); - } function renderTableLink(data, row) { if (!data || !row.applicationId) { @@ -199,14 +196,6 @@ $(function () { }); } - function safeParse(value) { - try { - return JSON.parse(value || '[]'); - } catch (error) { - console.warn('Unable to parse ApplicantAddresses data.', error); - return []; - } - } function buildSavePayload(zoneFormInstance, $form) { const modifiedFields = Array.from(zoneFormInstance.modifiedFields ?? []); @@ -252,58 +241,73 @@ $(function () { return payload; } - function isGuidEmpty(value) { - return !value || value === '00000000-0000-0000-0000-000000000000'; - } + +}); - function buildAddressPayload(addressId, prefix, $form) { - return { - id: addressId, - street: $form.find(`[name="${prefix}.Street"]`).val(), - street2: $form.find(`[name="${prefix}.Street2"]`).val(), - unit: $form.find(`[name="${prefix}.Unit"]`).val(), - city: $form.find(`[name="${prefix}.City"]`).val(), - province: $form.find(`[name="${prefix}.Province"]`).val(), - postalCode: $form.find(`[name="${prefix}.PostalCode"]`).val() - }; +function safeParse(value) { + try { + return JSON.parse(value || '[]'); + } catch (error) { + console.warn('Unable to parse ApplicantAddresses data.', error); + return []; + } +} + +function notifyApplicantAddressesLayoutChange() { + globalThis.dispatchEvent(new CustomEvent('applicant-addresses-layout-changed')); +} + +function isGuidEmpty(value) { + return !value || value === '00000000-0000-0000-0000-000000000000'; +} + +function buildAddressPayload(addressId, prefix, $form) { + return { + id: addressId, + street: $form.find(`[name="${prefix}.Street"]`).val(), + street2: $form.find(`[name="${prefix}.Street2"]`).val(), + unit: $form.find(`[name="${prefix}.Unit"]`).val(), + city: $form.find(`[name="${prefix}.City"]`).val(), + province: $form.find(`[name="${prefix}.Province"]`).val(), + postalCode: $form.find(`[name="${prefix}.PostalCode"]`).val() + }; +} + +function updateTablesAfterSave(payload, contactsDt, addressesDt) { + if (contactsDt && payload.primaryContact) { + contactsDt.rows().every(function () { + const rowData = this.data(); + if (rowData.id === payload.primaryContact.id) { + rowData.name = payload.primaryContact.fullName || ''; + rowData.email = payload.primaryContact.email || ''; + rowData.phone = payload.primaryContact.businessPhone || payload.primaryContact.cellPhone || ''; + rowData.title = payload.primaryContact.title || ''; + this.data(rowData); + } + }); + contactsDt.rows().invalidate().draw(false); } - function updateTablesAfterSave(payload, contactsDt, addressesDt) { - if (contactsDt && payload.primaryContact) { - contactsDt.rows().every(function () { + if (addressesDt) { + ['primaryPhysicalAddress', 'primaryMailingAddress'].forEach((key) => { + const addressPayload = payload[key]; + if (!addressPayload) { + return; + } + addressesDt.rows().every(function () { const rowData = this.data(); - if (rowData.id === payload.primaryContact.id) { - rowData.name = payload.primaryContact.fullName || ''; - rowData.email = payload.primaryContact.email || ''; - rowData.phone = payload.primaryContact.businessPhone || payload.primaryContact.cellPhone || ''; - rowData.title = payload.primaryContact.title || ''; + if (rowData.id === addressPayload.id) { + rowData.street = addressPayload.street || ''; + rowData.street2 = addressPayload.street2 || ''; + rowData.unit = addressPayload.unit || ''; + rowData.city = addressPayload.city || ''; + rowData.province = addressPayload.province || ''; + rowData.postal = addressPayload.postalCode || ''; this.data(rowData); } }); - contactsDt.rows().invalidate().draw(false); - } - - if (addressesDt) { - ['primaryPhysicalAddress', 'primaryMailingAddress'].forEach((key) => { - const addressPayload = payload[key]; - if (!addressPayload) { - return; - } - addressesDt.rows().every(function () { - const rowData = this.data(); - if (rowData.id === addressPayload.id) { - rowData.street = addressPayload.street || ''; - rowData.street2 = addressPayload.street2 || ''; - rowData.unit = addressPayload.unit || ''; - rowData.city = addressPayload.city || ''; - rowData.province = addressPayload.province || ''; - rowData.postal = addressPayload.postalCode || ''; - this.data(rowData); - } - }); - }); + }); - addressesDt.rows().invalidate().draw(false); - } + addressesDt.rows().invalidate().draw(false); } -}); +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs index 544ca94802..cc218ce1cc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantInfoViewComponent.cs @@ -135,6 +135,8 @@ public class ApplicantInfoScriptBundleContributor : BundleContributor { public override void ConfigureBundle(BundleConfigurationContext context) { + context.Files + .AddIfNotContains("/Views/Shared/Components/_Shared/string-utils.js"); context.Files .AddIfNotContains("/Views/Shared/Components/ApplicantInfo/Default.js"); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs index cc639c8dce..d8673b91f5 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/ApplicantSummaryViewModel.cs @@ -63,5 +63,8 @@ public class ApplicantSummaryViewModel [Display(Name = "ApplicantInfoView:ApplicantInfo.ApplicantName")] public string? ApplicantName { get; set; } + + [HiddenInput] + public bool IsDuplicated { get; set; } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml index ad1d64a50c..e5b8c96942 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.cshtml @@ -55,6 +55,7 @@ update-permission-requirement="@UnitySelector.Applicant.Summary.Update"> + @@ -386,6 +387,9 @@
Compare Accounts

Choose one account record as the principal, and choose the field values that you want to keep.

+
+ +
@column@Model.Columns[i]Actions
@@ -393,10 +397,12 @@ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js index 70f8fee38e..8b4667d530 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantInfo/Default.js @@ -273,7 +273,8 @@ function getExistingApplicantData() { SubSector: getVal('ApplicantSummary_SubSector'), SectorSubSectorIndustryDesc: getVal('ApplicantSummary_SectorSubSectorIndustryDesc'), FiscalDay: getVal('ApplicantSummary_FiscalDay'), - FiscalMonth: getVal('ApplicantSummary_FiscalMonth') + FiscalMonth: getVal('ApplicantSummary_FiscalMonth'), + IsDuplicated: $activeWidget.find('#ApplicantSummary_IsDuplicated').val() === 'True' }; } @@ -295,7 +296,8 @@ function createNewApplicantDataObject(selectedData) { SubSector: selectedData.SubSector || '', SectorSubSectorIndustryDesc: selectedData.SectorSubSectorIndustryDesc || '', FiscalDay: selectedData.FiscalDay || '', - FiscalMonth: selectedData.FiscalMonth || '' + FiscalMonth: selectedData.FiscalMonth || '', + IsDuplicated: selectedData.IsDuplicated }; } @@ -304,6 +306,21 @@ function populateMergeModal(existing, newData) { $('#existing_ApplicantNameHeader').text(existing.ApplicantName); $('#new_ApplicantNameHeader').text(newData.ApplicantName); + $('#mergeExistingDuplicateFlag').toggleClass('d-none', !existing.IsDuplicated); + $('#mergeNewDuplicateFlag').toggleClass('d-none', !newData.IsDuplicated); + + // Name match summary badge + let score = compareStrings(existing.ApplicantName || '', newData.ApplicantName || ''); + let $badge = $('#mergeNameMatchBadge'); + $badge.removeClass('unity-badge-warning'); + if (score >= 100) { + $badge.text('100% Matched - Possible Duplicate'); + } else if (score >= 50) { + $badge.text('Partially Matched'); + } else { + $badge.text('Not Matched').addClass('unity-badge-warning'); + } + for (const key in existing) { $(`#existing_${key}`).text(existing[key]); $(`#new_${key}`).text(newData[key]); @@ -369,7 +386,7 @@ async function executeMerge(existing, newData) { ApplicantInfoObj['worksheetId'] = worksheetId; ApplicantInfoObj.ApplicantId = principalApplicantId; - await handleApplicantMerge(applicationId, principalApplicantId, nonPrincipalApplicantId, newData, ApplicantInfoObj); + await handleApplicantMerge(applicationId, principalApplicantId, nonPrincipalApplicantId, ApplicantInfoObj); } // Helper function to setup merge modal handlers @@ -392,6 +409,7 @@ function setupMergeModalHandlers(existing, newData) { await executeMerge(existing, newData); } catch (err) { console.error('[MERGE ERROR]', err); + abp.notify.error('Merge failed. Please try again.'); } $('#mergeApplicantsSpinner').hide(); @@ -452,7 +470,8 @@ function initializeApplicantLookup() { SectorSubSectorIndustryDesc: item.SectorSubSectorIndustryDesc, FiscalDay: item.FiscalDay, FiscalMonth: item.FiscalMonth, - UnityApplicantId: item.UnityApplicantId + UnityApplicantId: item.UnityApplicantId, + IsDuplicated: item.IsDuplicated ?? false }; }); return { @@ -716,14 +735,9 @@ function getMergedApplicantInfo(existing, newData) { return merged; } -async function handleApplicantMerge(applicationId, principalApplicantId, nonPrincipalApplicantId, newData, ApplicantInfoObj) { - +async function handleApplicantMerge(applicationId, principalApplicantId, nonPrincipalApplicantId, ApplicantInfoObj) { await setApplicantDuplicatedStatus(principalApplicantId, nonPrincipalApplicantId); - - if (principalApplicantId === newData.ApplicantId) { - await updatePrincipalApplicant(applicationId, principalApplicantId); - } - + await transferApplicantApplications(principalApplicantId, nonPrincipalApplicantId); await updateMergedApplicant(applicationId, ApplicantInfoObj); } @@ -777,20 +791,15 @@ function setApplicantDuplicatedStatus(principalApplicantId, nonPrincipalApplican }); } -function updatePrincipalApplicant(applicationId, principalApplicantId) { +function transferApplicantApplications(principalApplicantId, nonPrincipalApplicantId) { return $.ajax({ - url: '/api/app/applicant/applicant-id', - type: 'PUT', - contentType: 'application/json', - data: JSON.stringify({ - applicationId: applicationId, - applicantId: principalApplicantId - }) - }) - .done(function () { - abp.notify.success('Principal Applicant updated successfully.'); + url: '/api/app/applicant/transfer-applicant-applications', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + principalApplicantId: principalApplicantId, + nonPrincipalApplicantId: nonPrincipalApplicantId }) - .fail(function (xhr, status) { - abp.notify.error('Failed to update Principal Applicant.'); - }); + }); } + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js index d0ab490685..24ea37ab77 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantSubmissions/Default.js @@ -1,6 +1,1149 @@ -$(function () { - const LAYOUT_NOTIFICATION_DELAYS = [0, 120, 280, 600]; +const LAYOUT_NOTIFICATION_DELAYS = [0, 120, 280, 600]; + +// Helper functions +function titleCase(str) { + if (!str) return ''; + str = str.toLowerCase().split(' '); + for (let i = 0; i < str.length; i++) { + str[i] = str[i].charAt(0).toUpperCase() + str[i].slice(1); + } + return str.join(' '); +} + +function convertToYesNo(str) { + switch (str) { + case true: + return "Yes"; + case false: + return "No"; + default: + return ''; + } +} + +function getFullType(code) { + const companyTypes = [ + { code: "BC", name: "BC Company" }, + { code: "CP", name: "Cooperative" }, + { code: "GP", name: "General Partnership" }, + { code: "S", name: "Society" }, + { code: "SP", name: "Sole Proprietorship" }, + { code: "A", name: "Extraprovincial Company" }, + { code: "B", name: "Extraprovincial" }, + { code: "BEN", name: "Benefit Company" }, + { code: "C", name: "Continuation In" }, + { code: "CC", name: "BC Community Contribution Company" }, + { code: "CS", name: "Continued In Society" }, + { code: "CUL", name: "Continuation In as a BC ULC" }, + { code: "EPR", name: "Extraprovincial Registration" }, + { code: "FI", name: "Financial Institution" }, + { code: "FOR", name: "Foreign Registration" }, + { code: "LIB", name: "Public Library Association" }, + { code: "LIC", name: "Licensed (Extra-Pro)" }, + { code: "LL", name: "Limited Liability Partnership" }, + { code: "LLC", name: "Limited Liability Company" }, + { code: "LP", name: "Limited Partnership" }, + { code: "MF", name: "Miscellaneous Firm" }, + { code: "PA", name: "Private Act" }, + { code: "PAR", name: "Parish" }, + { code: "QA", name: "CO 1860" }, + { code: "QB", name: "CO 1862" }, + { code: "QC", name: "CO 1878" }, + { code: "QD", name: "CO 1890" }, + { code: "QE", name: "CO 1897" }, + { code: "REG", name: "Registraton (Extra-pro)" }, + { code: "ULC", name: "BC Unlimited Liability Company" }, + { code: "XCP", name: "Extraprovincial Cooperative" }, + { code: "XL", name: "Extrapro Limited Liability Partnership" }, + { code: "XP", name: "Extraprovincial Limited Partnership" }, + { code: "XS", name: "Extraprovincial Society" } + ]; + const match = companyTypes.find(entry => entry.code === code); + return match ? match.name : "Unknown"; +} + +function payoutDefinition(approvedAmount, totalPaid) { + if ((approvedAmount > 0 && totalPaid > 0) && (approvedAmount === totalPaid)) { + return 'Fully Paid'; + } else if (totalPaid === 0) { + return ''; + } else { + return 'Partially Paid'; + } +} + +function getNames(data) { + let name = ''; + data.forEach((d, index) => { + name = name + (' ' + d.fullName + getDutyText(d)); + if (index != (data.length - 1)) { + name = name + ','; + } + }); + + return name; +} + +function getDutyText(data) { + return data.duty ? (" [" + data.duty + "]") : ''; +} + +function formatItems(items) { + const newData = items.map((item, index) => { + return { + ...item, + rowCount: index + }; + }); + return newData; +} + +function notifySubmissionsLayoutChange() { + globalThis.dispatchEvent(new CustomEvent('applicant-submissions-layout-changed')); +} + +function scheduleLayoutNotifications() { + LAYOUT_NOTIFICATION_DELAYS.forEach((delay) => { + setTimeout(notifySubmissionsLayoutChange, delay); + }); +} + +function bindLayoutNotificationEvents(dataTable) { + dataTable.on('draw', notifySubmissionsLayoutChange); +} + +function updateOpenButtonState(dataTable) { + const selectedRows = dataTable.rows({ selected: true }).data(); + const $openBtn = $('#openSubmissionBtn'); + + if (selectedRows.length === 1) { + $openBtn.prop('disabled', false).show(); + } else { + $openBtn.prop('disabled', true).hide(); + } +} + +// Column getter functions +function getSelectColumn(columnIndex) { + return { + title: '', + data: 'rowCount', + name: 'select', + orderable: false, + className: 'notexport dt-checkboxes-cell', + checkboxes: { + selectRow: true, + selectAllRender: '', + }, + render: function (data, type, row) { + return ''; + }, + index: columnIndex + }; +} + +function getReferenceNoColumn(columnIndex) { + return { + title: 'Submission #', + data: 'referenceNo', + name: 'referenceNo', + className: 'data-table-header text-nowrap', + render: function (data, type, row) { + return `${data || ''}`; + }, + index: columnIndex + }; +} + +function getApplicantNameColumn(columnIndex) { + return { + title: 'Applicant Name', + data: 'applicant.applicantName', + name: 'applicantName', + className: 'data-table-header', + index: columnIndex + }; +} + +function getCategoryColumn(columnIndex, l) { + return { + title: 'Category', + data: 'category', + name: 'category', + className: 'data-table-header', + index: columnIndex + }; +} + +function getSubmissionDateColumn(columnIndex, l) { + return { + title: l('SubmissionDate'), + data: 'submissionDate', + name: 'submissionDate', + className: 'data-table-header', + index: columnIndex, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + }; +} + +function getProjectNameColumn(columnIndex) { + return { + title: 'Project Name', + data: 'projectName', + name: 'projectName', + className: 'data-table-header', + index: columnIndex + }; +} + +function getSectorColumn(columnIndex) { + return { + title: 'Sector', + name: 'sector', + data: 'applicant.sector', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSubSectorColumn(columnIndex) { + return { + title: 'SubSector', + name: 'subsector', + data: 'applicant.subSector', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getTotalProjectBudgetColumn(columnIndex, formatter) { + return { + title: 'Total Project Budget', + name: 'totalProjectBudget', + data: 'totalProjectBudget', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data); + }, + index: columnIndex + }; +} + +function getAssigneesColumn(columnIndex, l) { + return { + title: l('Assignee'), + data: 'assignees', + name: 'assignees', + className: 'dt-editable', + render: function (data, type, row) { + let displayText = ' '; + + if (data?.length == 1) { + displayText = type === 'fullName' ? getNames(data) : (data[0].fullName + getDutyText(data[0])); + } else if (data.length > 1) { + displayText = getNames(data); + } + + return ` + + ' + displayText + '' + + ``; + }, + index: columnIndex + }; +} + +function getStatusColumn(columnIndex, l) { + return { + title: l('GrantApplicationStatus'), + data: 'status', + name: 'status', + className: 'data-table-header', + index: columnIndex + }; +} + +function getRequestedAmountColumn(columnIndex, l, formatter) { + return { + title: l('RequestedAmount'), + data: 'requestedAmount', + name: 'requestedAmount', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data); + }, + index: columnIndex + }; +} + +function getApprovedAmountColumn(columnIndex, formatter) { + return { + title: 'Approved Amount', + name: 'approvedAmount', + data: 'approvedAmount', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data); + }, + index: columnIndex + }; +} + +function getEconomicRegionColumn(columnIndex) { + return { + title: 'Economic Region', + name: 'economicRegion', + data: 'economicRegion', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getRegionalDistrictColumn(columnIndex) { + return { + title: 'Regional District', + name: 'regionalDistrict', + data: 'regionalDistrict', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getCommunityColumn(columnIndex) { + return { + title: 'Community', + name: 'community', + data: 'community', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getOrganizationNumberColumn(columnIndex, l) { + return { + title: l('ApplicantInfoView:ApplicantInfo.OrgNumber'), + name: 'orgNumber', + data: 'applicant.orgNumber', + className: 'data-table-header', + visible: false, + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getOrgBookStatusColumn(columnIndex) { + return { + title: 'Org Book Status', + name: 'orgBookStatus', + data: 'applicant.orgStatus', + className: 'data-table-header', + render: function (data) { + if (data === 'ACTIVE') { + return 'Active'; + } else if (data === 'HISTORICAL') { + return 'Historical'; + } else { + return data ?? ''; + } + }, + index: columnIndex + }; +} + +function getProjectStartDateColumn(columnIndex) { + return { + title: 'Project Start Date', + name: 'projectStartDate', + data: 'projectStartDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getProjectEndDateColumn(columnIndex) { + return { + title: 'Project End Date', + name: 'projectEndDate', + data: 'projectEndDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getProjectedFundingTotalColumn(columnIndex, formatter) { + return { + title: 'Projected Funding Total', + name: 'projectFundingTotal', + data: 'projectFundingTotal', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data) ?? ''; + }, + index: columnIndex + }; +} + +function getTotalProjectBudgetPercentageColumn(columnIndex) { + return { + title: '% of Total Project Budget', + name: 'percentageTotalProjectBudget', + data: 'percentageTotalProjectBudget', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getTotalPaidAmountColumn(columnIndex, formatter) { + return { + title: 'Total Paid Amount $', + name: 'totalPaidAmount', + data: 'paymentInfo', + className: 'data-table-header currency-display', + render: function (data) { + let totalPaid = data?.totalPaid ?? ''; + return formatter.format(totalPaid); + }, + index: columnIndex + }; +} + +function getElectoralDistrictColumn(columnIndex) { + return { + title: 'Project Electoral District', + name: 'electoralDistrict', + data: 'electoralDistrict', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getApplicantElectoralDistrictColumn(columnIndex) { + return { + title: 'Applicant Electoral District', + name: 'applicantElectoralDistrict', + data: 'applicantElectoralDistrict', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getForestryOrNonForestryColumn(columnIndex) { + return { + title: 'Forestry or Non-Forestry', + name: 'forestryOrNonForestry', + data: 'forestry', + className: 'data-table-header', + render: function (data) { + if (data) + return data == 'FORESTRY' ? 'Forestry' : 'Non Forestry'; + else + return ''; + }, + index: columnIndex + }; +} + +function getForestryFocusColumn(columnIndex) { + return { + title: 'Forestry Focus', + name: 'forestryFocus', + data: 'forestryFocus', + className: 'data-table-header', + render: function (data) { + if (!data) { + return ''; + } + if (data == 'PRIMARY') { + return 'Primary processing'; + } else if (data == 'SECONDARY') { + return 'Secondary/Value-Added/Not Mass Timber'; + } else if (data == 'MASS_TIMBER') { + return 'Mass Timber'; + } else { + return data; + } + }, + index: columnIndex + }; +} + +function getAcquisitionColumn(columnIndex) { + return { + title: 'Acquisition', + name: 'acquisition', + data: 'acquisition', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getCityColumn(columnIndex) { + return { + title: 'City', + name: 'city', + data: 'city', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getCommunityPopulationColumn(columnIndex) { + return { + title: 'Community Population', + name: 'communityPopulation', + data: 'communityPopulation', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getLikelihoodOfFundingColumn(columnIndex) { + return { + title: 'Likelihood of Funding', + name: 'likelihoodOfFunding', + data: 'likelihoodOfFunding', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getSubStatusColumn(columnIndex) { + return { + title: 'Sub-Status', + name: 'subStatusDisplayValue', + data: 'subStatusDisplayValue', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getTagsColumn(columnIndex) { + return { + title: 'Tags', + name: 'applicationTag', + data: 'applicationTag', + className: '', + render: function (data) { + if (data && Array.isArray(data)) { + let tagNames = data + .filter(x => x?.tag?.name) + .map(x => x.tag.name); + return tagNames.join(', ') ?? ''; + } + return ''; + }, + index: columnIndex + }; +} + +function getTotalScoreColumn(columnIndex) { + return { + title: 'Total Score', + name: 'totalScore', + data: 'totalScore', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getAssessmentResultColumn(columnIndex) { + return { + title: 'Assessment Result', + name: 'assessmentResult', + data: 'assessmentResultStatus', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getRecommendedAmountColumn(columnIndex, formatter) { + return { + title: 'Recommended Amount', + name: 'recommendedAmount', + data: 'recommendedAmount', + className: 'data-table-header currency-display', + render: function (data) { + return formatter.format(data) ?? ''; + }, + index: columnIndex + }; +} + +function getDueDateColumn(columnIndex) { + return { + title: 'Due Date', + name: 'dueDate', + data: 'dueDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getOwnerColumn(columnIndex) { + return { + title: 'Owner', + name: 'Owner', + data: 'owner', + className: 'data-table-header', + render: function (data) { + return data ? data.fullName : ''; + }, + index: columnIndex + }; +} + +function getDecisionDateColumn(columnIndex) { + return { + title: 'Decision Date', + name: 'finalDecisionDate', + data: 'finalDecisionDate', + className: 'data-table-header', + render: function (data) { + return data ? luxon.DateTime.fromISO(data, { + locale: abp.localization.currentCulture.name, + }).toUTC().toLocaleString() : ''; + }, + index: columnIndex + }; +} + +function getProjectSummaryColumn(columnIndex) { + return { + title: 'Project Summary', + name: 'projectSummary', + data: 'projectSummary', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getOrganizationTypeColumn(columnIndex) { + return { + title: 'Organization Type', + name: 'organizationType', + data: 'organizationType', + className: 'data-table-header', + render: function (data) { + return getFullType(data) ?? ''; + }, + index: columnIndex + }; +} + +function getOrganizationNameColumn(columnIndex, l) { + return { + title: l('Summary:Application.OrganizationName'), + name: 'organizationName', + data: 'organizationName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getBusinessNumberColumn(columnIndex, l) { + return { + title: l('Summary:Application.BusinessNumber'), + name: 'businessNumber', + data: 'applicant.businessNumber', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getNonRegisteredOrganizationNameColumn(columnIndex, l) { + return { + title: l('Summary:Application.NonRegOrgName'), + name: 'nonRegOrgName', + data: 'nonRegOrgName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getUnityApplicationIdColumn(columnIndex) { + return { + title: 'Unity Application ID', + name: 'unityApplicationId', + data: 'unityApplicationId', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getDueDiligenceStatusColumn(columnIndex) { + return { + title: 'Due Diligence Status', + name: 'dueDiligenceStatus', + data: 'dueDiligenceStatus', + className: 'data-table-header', + render: function (data) { + return titleCase(data ?? '') ?? ''; + }, + index: columnIndex + }; +} + +function getDeclineRationaleColumn(columnIndex) { + return { + title: 'Decline Rationale', + name: 'declineRationale', + data: 'declineRational', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactFullNameColumn(columnIndex) { + return { + title: 'Contact Full Name', + name: 'contactFullName', + data: 'contactFullName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactTitleColumn(columnIndex) { + return { + title: 'Contact Title', + name: 'contactTitle', + data: 'contactTitle', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactEmailColumn(columnIndex) { + return { + title: 'Contact Email', + name: 'contactEmail', + data: 'contactEmail', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactBusinessPhoneColumn(columnIndex) { + return { + title: 'Contact Business Phone', + name: 'contactBusinessPhone', + data: 'contactBusinessPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getContactCellPhoneColumn(columnIndex) { + return { + title: 'Contact Cell Phone', + name: 'contactCellPhone', + data: 'contactCellPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSectorSubSectorIndustryDescColumn(columnIndex) { + return { + title: 'Other Sector/Sub/Industry Description', + name: 'sectorSubSectorIndustryDesc', + data: 'applicant.sectorSubSectorIndustryDesc', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityFullNameColumn(columnIndex) { + return { + title: 'Signing Authority Full Name', + name: 'signingAuthorityFullName', + data: 'signingAuthorityFullName', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityTitleColumn(columnIndex) { + return { + title: 'Signing Authority Title', + name: 'signingAuthorityTitle', + data: 'signingAuthorityTitle', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityEmailColumn(columnIndex) { + return { + title: 'Signing Authority Email', + name: 'signingAuthorityEmail', + data: 'signingAuthorityEmail', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityBusinessPhoneColumn(columnIndex) { + return { + title: 'Signing Authority Business Phone', + name: 'signingAuthorityBusinessPhone', + data: 'signingAuthorityBusinessPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getSigningAuthorityCellPhoneColumn(columnIndex) { + return { + title: 'Signing Authority Cell Phone', + name: 'signingAuthorityCellPhone', + data: 'signingAuthorityCellPhone', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getPlaceColumn(columnIndex) { + return { + title: 'Place', + name: 'place', + data: 'place', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getRiskRankingColumn(columnIndex) { + return { + title: 'Risk Ranking', + name: 'riskranking', + data: 'riskRanking', + className: 'data-table-header', + render: function (data) { + return titleCase(data ?? '') ?? ''; + }, + index: columnIndex + }; +} + +function getNotesColumn(columnIndex) { + return { + title: 'Notes', + name: 'notes', + data: 'notes', + className: 'data-table-header multi-line', + width: "20rem", + createdCell: function (td) { + $(td).css('min-width', '20rem'); + }, + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getRedStopColumn(columnIndex) { + return { + title: 'Red-Stop', + name: 'redstop', + data: 'applicant.redStop', + className: 'data-table-header', + render: function (data) { + return convertToYesNo(data); + }, + index: columnIndex + }; +} + +function getIndigenousColumn(columnIndex) { + return { + title: 'Indigenous', + name: 'indigenous', + data: 'applicant.indigenousOrgInd', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getFyeDayColumn(columnIndex) { + return { + title: 'FYE Day', + name: 'fyeDay', + data: 'applicant.fiscalDay', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getFyeMonthColumn(columnIndex) { + return { + title: 'FYE Month', + name: 'fyeMonth', + data: 'applicant.fiscalMonth', + className: 'data-table-header', + render: function (data) { + if (data) { + return titleCase(data); + } + else { + return ''; + } + }, + index: columnIndex + }; +} + +function getApplicantIdColumn(columnIndex) { + return { + title: 'Applicant Id', + name: 'applicantId', + data: 'applicant.unityApplicantId', + className: 'data-table-header', + render: function (data) { + return data ?? ''; + }, + index: columnIndex + }; +} + +function getPayoutColumn(columnIndex) { + return { + title: 'Payout', + name: 'paymentInfo', + data: 'paymentInfo', + className: 'data-table-header', + render: function (data) { + return payoutDefinition(data?.approvedAmount ?? 0, data?.totalPaid ?? 0); + }, + index: columnIndex + }; +} +function responseCallback(result) { + return { + recordsTotal: result.totalCount, + recordsFiltered: result.totalCount, + data: formatItems(result.items) + }; +} + +function getColumns(formatter, l) { + let columnIndex = 0; + const sortedColumns = [ + getSelectColumn(columnIndex++), + getReferenceNoColumn(columnIndex++), + getCategoryColumn(columnIndex++, l), + getSubmissionDateColumn(columnIndex++, l), + getStatusColumn(columnIndex++, l), + getRequestedAmountColumn(columnIndex++, l, formatter), + getApprovedAmountColumn(columnIndex++, formatter), + getApplicantNameColumn(columnIndex++), + getProjectNameColumn(columnIndex++), + getSectorColumn(columnIndex++), + getSubSectorColumn(columnIndex++), + getTotalProjectBudgetColumn(columnIndex++, formatter), + getAssigneesColumn(columnIndex++, l), + getEconomicRegionColumn(columnIndex++), + getRegionalDistrictColumn(columnIndex++), + getCommunityColumn(columnIndex++), + getOrganizationNumberColumn(columnIndex++, l), + getOrgBookStatusColumn(columnIndex++), + getProjectStartDateColumn(columnIndex++), + getProjectEndDateColumn(columnIndex++), + getProjectedFundingTotalColumn(columnIndex++, formatter), + getTotalProjectBudgetPercentageColumn(columnIndex++), + getTotalPaidAmountColumn(columnIndex++, formatter), + getElectoralDistrictColumn(columnIndex++), + getApplicantElectoralDistrictColumn(columnIndex++), + getForestryOrNonForestryColumn(columnIndex++), + getForestryFocusColumn(columnIndex++), + getAcquisitionColumn(columnIndex++), + getCityColumn(columnIndex++), + getCommunityPopulationColumn(columnIndex++), + getLikelihoodOfFundingColumn(columnIndex++), + getSubStatusColumn(columnIndex++), + getTagsColumn(columnIndex++), + getTotalScoreColumn(columnIndex++), + getAssessmentResultColumn(columnIndex++), + getRecommendedAmountColumn(columnIndex++, formatter), + getDueDateColumn(columnIndex++), + getOwnerColumn(columnIndex++), + getDecisionDateColumn(columnIndex++), + getProjectSummaryColumn(columnIndex++), + getOrganizationTypeColumn(columnIndex++), + getOrganizationNameColumn(columnIndex++, l), + getBusinessNumberColumn(columnIndex++, l), + getDueDiligenceStatusColumn(columnIndex++), + getDeclineRationaleColumn(columnIndex++), + getContactFullNameColumn(columnIndex++), + getContactTitleColumn(columnIndex++), + getContactEmailColumn(columnIndex++), + getContactBusinessPhoneColumn(columnIndex++), + getContactCellPhoneColumn(columnIndex++), + getSectorSubSectorIndustryDescColumn(columnIndex++), + getSigningAuthorityFullNameColumn(columnIndex++), + getSigningAuthorityTitleColumn(columnIndex++), + getSigningAuthorityEmailColumn(columnIndex++), + getSigningAuthorityBusinessPhoneColumn(columnIndex++), + getSigningAuthorityCellPhoneColumn(columnIndex++), + getPlaceColumn(columnIndex++), + getRiskRankingColumn(columnIndex++), + getNotesColumn(columnIndex++), + getRedStopColumn(columnIndex++), + getIndigenousColumn(columnIndex++), + getFyeDayColumn(columnIndex++), + getFyeMonthColumn(columnIndex++), + getApplicantIdColumn(columnIndex++), + getPayoutColumn(columnIndex++), + getNonRegisteredOrganizationNameColumn(columnIndex++, l), + getUnityApplicationIdColumn(columnIndex++) + ].map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })) + .sort((a, b) => a.index - b.index); + return sortedColumns; +} + +$(function () { // Check if createNumberFormatter exists if (typeof createNumberFormatter !== 'function') { console.error('createNumberFormatter is not defined. Ensure table-utils.js is loaded before this script'); @@ -45,26 +1188,7 @@ $(function () { const submissionsData = submissionsDataJson ? JSON.parse(submissionsDataJson) : []; // Get all columns - const listColumns = getColumns(); - - // Response callback - same pattern as Application List - const responseCallback = function (result) { - return { - recordsTotal: result.totalCount, - recordsFiltered: result.totalCount, - data: formatItems(result.items) - }; - }; - - const formatItems = function (items) { - const newData = items.map((item, index) => { - return { - ...item, - rowCount: index - }; - }); - return newData; - }; + const listColumns = getColumns(formatter, l); // Mock service that returns embedded data (simulating API endpoint) // Must return a jQuery Deferred object (not native Promise) for ABP compatibility @@ -99,1157 +1223,26 @@ $(function () { dynamicButtonContainerId: 'submissionsDynamicButtonContainerId' }); - function notifySubmissionsLayoutChange() { - window.dispatchEvent(new CustomEvent('applicant-submissions-layout-changed')); - } - scheduleLayoutNotifications(); - bindLayoutNotificationEvents(); + bindLayoutNotificationEvents(dataTable); // External search binding dataTable.externalSearch('#submissions-search', { delay: 300 }); // Open button handling - function updateOpenButtonState() { - const selectedRows = dataTable.rows({ selected: true }).data(); - const $openBtn = $('#openSubmissionBtn'); - - if (selectedRows.length === 1) { - $openBtn.prop('disabled', false).show(); - } else { - $openBtn.prop('disabled', true).hide(); - } - } - dataTable.on('select deselect', function () { - updateOpenButtonState(); + updateOpenButtonState(dataTable); }); $('#openSubmissionBtn').on('click', function () { const selectedRows = dataTable.rows({ selected: true }).data(); if (selectedRows.length === 1) { - window.location.href = `/GrantApplications/Details?ApplicationId=${selectedRows[0].id}`; + globalThis.location.href = `/GrantApplications/Details?ApplicationId=${selectedRows[0].id}`; } }); // Initialize button state - updateOpenButtonState(); - - function scheduleLayoutNotifications() { - LAYOUT_NOTIFICATION_DELAYS.forEach((delay) => { - setTimeout(notifySubmissionsLayoutChange, delay); - }); - } - - function bindLayoutNotificationEvents() { - dataTable.on('draw', notifySubmissionsLayoutChange); - } - - // Column getter functions (from Application List) - function getColumns() { - let columnIndex = 0; - const sortedColumns = [ - getSelectColumn(columnIndex++), - getReferenceNoColumn(columnIndex++), - getCategoryColumn(columnIndex++), - getSubmissionDateColumn(columnIndex++), - getStatusColumn(columnIndex++), - getRequestedAmountColumn(columnIndex++), - getApprovedAmountColumn(columnIndex++), - getApplicantNameColumn(columnIndex++), - getProjectNameColumn(columnIndex++), - getSectorColumn(columnIndex++), - getSubSectorColumn(columnIndex++), - getTotalProjectBudgetColumn(columnIndex++), - getAssigneesColumn(columnIndex++), - getEconomicRegionColumn(columnIndex++), - getRegionalDistrictColumn(columnIndex++), - getCommunityColumn(columnIndex++), - getOrganizationNumberColumn(columnIndex++), - getOrgBookStatusColumn(columnIndex++), - getProjectStartDateColumn(columnIndex++), - getProjectEndDateColumn(columnIndex++), - getProjectedFundingTotalColumn(columnIndex++), - getTotalProjectBudgetPercentageColumn(columnIndex++), - getTotalPaidAmountColumn(columnIndex++), - getElectoralDistrictColumn(columnIndex++), - getApplicantElectoralDistrictColumn(columnIndex++), - getForestryOrNonForestryColumn(columnIndex++), - getForestryFocusColumn(columnIndex++), - getAcquisitionColumn(columnIndex++), - getCityColumn(columnIndex++), - getCommunityPopulationColumn(columnIndex++), - getLikelihoodOfFundingColumn(columnIndex++), - getSubStatusColumn(columnIndex++), - getTagsColumn(columnIndex++), - getTotalScoreColumn(columnIndex++), - getAssessmentResultColumn(columnIndex++), - getRecommendedAmountColumn(columnIndex++), - getDueDateColumn(columnIndex++), - getOwnerColumn(columnIndex++), - getDecisionDateColumn(columnIndex++), - getProjectSummaryColumn(columnIndex++), - getOrganizationTypeColumn(columnIndex++), - getOrganizationNameColumn(columnIndex++), - getBusinessNumberColumn(columnIndex++), - getDueDiligenceStatusColumn(columnIndex++), - getDeclineRationaleColumn(columnIndex++), - getContactFullNameColumn(columnIndex++), - getContactTitleColumn(columnIndex++), - getContactEmailColumn(columnIndex++), - getContactBusinessPhoneColumn(columnIndex++), - getContactCellPhoneColumn(columnIndex++), - getSectorSubSectorIndustryDescColumn(columnIndex++), - getSigningAuthorityFullNameColumn(columnIndex++), - getSigningAuthorityTitleColumn(columnIndex++), - getSigningAuthorityEmailColumn(columnIndex++), - getSigningAuthorityBusinessPhoneColumn(columnIndex++), - getSigningAuthorityCellPhoneColumn(columnIndex++), - getPlaceColumn(columnIndex++), - getRiskRankingColumn(columnIndex++), - getNotesColumn(columnIndex++), - getRedStopColumn(columnIndex++), - getIndigenousColumn(columnIndex++), - getFyeDayColumn(columnIndex++), - getFyeMonthColumn(columnIndex++), - getApplicantIdColumn(columnIndex++), - getPayoutColumn(columnIndex++), - getNonRegisteredOrganizationNameColumn(columnIndex++), - getUnityApplicationIdColumn(columnIndex++) - ].map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })) - .sort((a, b) => a.index - b.index); - return sortedColumns; - } - - // Select column - function getSelectColumn(columnIndex) { - return { - title: '', - data: 'rowCount', - name: 'select', - orderable: false, - className: 'notexport dt-checkboxes-cell', - checkboxes: { - selectRow: true, - selectAllRender: '', - }, - render: function (data, type, row) { - return ''; - }, - index: columnIndex - }; - } - - // Submission # (referenceNo) - clickable link to Application Details - function getReferenceNoColumn(columnIndex) { - return { - title: 'Submission #', - data: 'referenceNo', - name: 'referenceNo', - className: 'data-table-header text-nowrap', - render: function (data, type, row) { - return `${data || ''}`; - }, - index: columnIndex - }; - } - - // All other column definitions copied from Application List - function getApplicantNameColumn(columnIndex) { - return { - title: 'Applicant Name', - data: 'applicant.applicantName', - name: 'applicantName', - className: 'data-table-header', - index: columnIndex - }; - } - - function getCategoryColumn(columnIndex) { - return { - title: 'Category', - data: 'category', - name: 'category', - className: 'data-table-header', - index: columnIndex - }; - } - - function getSubmissionDateColumn(columnIndex) { - return { - title: l('SubmissionDate'), - data: 'submissionDate', - name: 'submissionDate', - className: 'data-table-header', - index: columnIndex, - render: function (data, type) { - return DateUtils.formatUtcDateToLocal(data, type); - } - }; - } - - function getProjectNameColumn(columnIndex) { - return { - title: 'Project Name', - data: 'projectName', - name: 'projectName', - className: 'data-table-header', - index: columnIndex - }; - } - - function getSectorColumn(columnIndex) { - return { - title: 'Sector', - name: 'sector', - data: 'applicant.sector', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSubSectorColumn(columnIndex) { - return { - title: 'SubSector', - name: 'subsector', - data: 'applicant.subSector', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getTotalProjectBudgetColumn(columnIndex) { - return { - title: 'Total Project Budget', - name: 'totalProjectBudget', - data: 'totalProjectBudget', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data); - }, - index: columnIndex - }; - } - - function getAssigneesColumn(columnIndex) { - return { - title: l('Assignee'), - data: 'assignees', - name: 'assignees', - className: 'dt-editable', - render: function (data, type, row) { - let displayText = ' '; - - if (data != null && data.length == 1) { - displayText = type === 'fullName' ? getNames(data) : (data[0].fullName + getDutyText(data[0])); - } else if (data.length > 1) { - displayText = getNames(data); - } - - return ` - - ' + displayText + '' + - ``; - }, - index: columnIndex - }; - } - - function getStatusColumn(columnIndex) { - return { - title: l('GrantApplicationStatus'), - data: 'status', - name: 'status', - className: 'data-table-header', - index: columnIndex - }; - } - - function getRequestedAmountColumn(columnIndex) { - return { - title: l('RequestedAmount'), - data: 'requestedAmount', - name: 'requestedAmount', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data); - }, - index: columnIndex - }; - } - - function getApprovedAmountColumn(columnIndex) { - return { - title: 'Approved Amount', - name: 'approvedAmount', - data: 'approvedAmount', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data); - }, - index: columnIndex - }; - } + updateOpenButtonState(dataTable); - function getEconomicRegionColumn(columnIndex) { - return { - title: 'Economic Region', - name: 'economicRegion', - data: 'economicRegion', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } +}); - function getRegionalDistrictColumn(columnIndex) { - return { - title: 'Regional District', - name: 'regionalDistrict', - data: 'regionalDistrict', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getCommunityColumn(columnIndex) { - return { - title: 'Community', - name: 'community', - data: 'community', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getOrganizationNumberColumn(columnIndex) { - return { - title: l('ApplicantInfoView:ApplicantInfo.OrgNumber'), - name: 'orgNumber', - data: 'applicant.orgNumber', - className: 'data-table-header', - visible: false, - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getOrgBookStatusColumn(columnIndex) { - return { - title: 'Org Book Status', - name: 'orgBookStatus', - data: 'applicant.orgStatus', - className: 'data-table-header', - render: function (data) { - if (data != null && data == 'ACTIVE') { - return 'Active'; - } else if (data != null && data == 'HISTORICAL') { - return 'Historical'; - } else { - return data ?? ''; - } - }, - index: columnIndex - }; - } - - function getProjectStartDateColumn(columnIndex) { - return { - title: 'Project Start Date', - name: 'projectStartDate', - data: 'projectStartDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getProjectEndDateColumn(columnIndex) { - return { - title: 'Project End Date', - name: 'projectEndDate', - data: 'projectEndDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getProjectedFundingTotalColumn(columnIndex) { - return { - title: 'Projected Funding Total', - name: 'projectFundingTotal', - data: 'projectFundingTotal', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data) ?? ''; - }, - index: columnIndex - }; - } - - function getTotalProjectBudgetPercentageColumn(columnIndex) { - return { - title: '% of Total Project Budget', - name: 'percentageTotalProjectBudget', - data: 'percentageTotalProjectBudget', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getTotalPaidAmountColumn(columnIndex) { - return { - title: 'Total Paid Amount $', - name: 'totalPaidAmount', - data: 'paymentInfo', - className: 'data-table-header currency-display', - render: function (data) { - let totalPaid = data?.totalPaid ?? ''; - return formatter.format(totalPaid); - }, - index: columnIndex - }; - } - - function getElectoralDistrictColumn(columnIndex) { - return { - title: 'Project Electoral District', - name: 'electoralDistrict', - data: 'electoralDistrict', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getApplicantElectoralDistrictColumn(columnIndex) { - return { - title: 'Applicant Electoral District', - name: 'applicantElectoralDistrict', - data: 'applicantElectoralDistrict', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getForestryOrNonForestryColumn(columnIndex) { - return { - title: 'Forestry or Non-Forestry', - name: 'forestryOrNonForestry', - data: 'forestry', - className: 'data-table-header', - render: function (data) { - if (data != null) - return data == 'FORESTRY' ? 'Forestry' : 'Non Forestry'; - else - return ''; - }, - index: columnIndex - }; - } - - function getForestryFocusColumn(columnIndex) { - return { - title: 'Forestry Focus', - name: 'forestryFocus', - data: 'forestryFocus', - className: 'data-table-header', - render: function (data) { - if (data) { - if (data == 'PRIMARY') { - return 'Primary processing' - } - else if (data == 'SECONDARY') { - return 'Secondary/Value-Added/Not Mass Timber' - } else if (data == 'MASS_TIMBER') { - return 'Mass Timber'; - } else if (data != '') { - return data; - } else { - return ''; - } - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getAcquisitionColumn(columnIndex) { - return { - title: 'Acquisition', - name: 'acquisition', - data: 'acquisition', - className: 'data-table-header', - render: function (data) { - if (data) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getCityColumn(columnIndex) { - return { - title: 'City', - name: 'city', - data: 'city', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getCommunityPopulationColumn(columnIndex) { - return { - title: 'Community Population', - name: 'communityPopulation', - data: 'communityPopulation', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getLikelihoodOfFundingColumn(columnIndex) { - return { - title: 'Likelihood of Funding', - name: 'likelihoodOfFunding', - data: 'likelihoodOfFunding', - className: 'data-table-header', - render: function (data) { - if (data != null) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getSubStatusColumn(columnIndex) { - return { - title: 'Sub-Status', - name: 'subStatusDisplayValue', - data: 'subStatusDisplayValue', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getTagsColumn(columnIndex) { - return { - title: 'Tags', - name: 'applicationTag', - data: 'applicationTag', - className: '', - render: function (data) { - if (data && Array.isArray(data)) { - let tagNames = data - .filter(x => x?.tag?.name) - .map(x => x.tag.name); - return tagNames.join(', ') ?? ''; - } - return ''; - }, - index: columnIndex - }; - } - - function getTotalScoreColumn(columnIndex) { - return { - title: 'Total Score', - name: 'totalScore', - data: 'totalScore', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getAssessmentResultColumn(columnIndex) { - return { - title: 'Assessment Result', - name: 'assessmentResult', - data: 'assessmentResultStatus', - className: 'data-table-header', - render: function (data) { - if (data != null) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getRecommendedAmountColumn(columnIndex) { - return { - title: 'Recommended Amount', - name: 'recommendedAmount', - data: 'recommendedAmount', - className: 'data-table-header currency-display', - render: function (data) { - return formatter.format(data) ?? ''; - }, - index: columnIndex - }; - } - - function getDueDateColumn(columnIndex) { - return { - title: 'Due Date', - name: 'dueDate', - data: 'dueDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getOwnerColumn(columnIndex) { - return { - title: 'Owner', - name: 'Owner', - data: 'owner', - className: 'data-table-header', - render: function (data) { - return data != null ? data.fullName : ''; - }, - index: columnIndex - }; - } - - function getDecisionDateColumn(columnIndex) { - return { - title: 'Decision Date', - name: 'finalDecisionDate', - data: 'finalDecisionDate', - className: 'data-table-header', - render: function (data) { - return data != null ? luxon.DateTime.fromISO(data, { - locale: abp.localization.currentCulture.name, - }).toUTC().toLocaleString() : ''; - }, - index: columnIndex - }; - } - - function getProjectSummaryColumn(columnIndex) { - return { - title: 'Project Summary', - name: 'projectSummary', - data: 'projectSummary', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getOrganizationTypeColumn(columnIndex) { - return { - title: 'Organization Type', - name: 'organizationType', - data: 'organizationType', - className: 'data-table-header', - render: function (data) { - return getFullType(data) ?? ''; - }, - index: columnIndex - }; - } - - function getOrganizationNameColumn(columnIndex) { - return { - title: l('Summary:Application.OrganizationName'), - name: 'organizationName', - data: 'organizationName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getBusinessNumberColumn(columnIndex) { - return { - title: l('Summary:Application.BusinessNumber'), - name: 'businessNumber', - data: 'applicant.businessNumber', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getNonRegisteredOrganizationNameColumn(columnIndex) { - return { - title: l('Summary:Application.NonRegOrgName'), - name: 'nonRegOrgName', - data: 'nonRegOrgName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getUnityApplicationIdColumn(columnIndex) { - return { - title: 'Unity Application ID', - name: 'unityApplicationId', - data: 'unityApplicationId', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getDueDiligenceStatusColumn(columnIndex) { - return { - title: 'Due Diligence Status', - name: 'dueDiligenceStatus', - data: 'dueDiligenceStatus', - className: 'data-table-header', - render: function (data) { - return titleCase(data ?? '') ?? ''; - }, - index: columnIndex - }; - } - - function getDeclineRationaleColumn(columnIndex) { - return { - title: 'Decline Rationale', - name: 'declineRationale', - data: 'declineRational', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactFullNameColumn(columnIndex) { - return { - title: 'Contact Full Name', - name: 'contactFullName', - data: 'contactFullName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactTitleColumn(columnIndex) { - return { - title: 'Contact Title', - name: 'contactTitle', - data: 'contactTitle', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactEmailColumn(columnIndex) { - return { - title: 'Contact Email', - name: 'contactEmail', - data: 'contactEmail', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactBusinessPhoneColumn(columnIndex) { - return { - title: 'Contact Business Phone', - name: 'contactBusinessPhone', - data: 'contactBusinessPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getContactCellPhoneColumn(columnIndex) { - return { - title: 'Contact Cell Phone', - name: 'contactCellPhone', - data: 'contactCellPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSectorSubSectorIndustryDescColumn(columnIndex) { - return { - title: 'Other Sector/Sub/Industry Description', - name: 'sectorSubSectorIndustryDesc', - data: 'applicant.sectorSubSectorIndustryDesc', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityFullNameColumn(columnIndex) { - return { - title: 'Signing Authority Full Name', - name: 'signingAuthorityFullName', - data: 'signingAuthorityFullName', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityTitleColumn(columnIndex) { - return { - title: 'Signing Authority Title', - name: 'signingAuthorityTitle', - data: 'signingAuthorityTitle', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityEmailColumn(columnIndex) { - return { - title: 'Signing Authority Email', - name: 'signingAuthorityEmail', - data: 'signingAuthorityEmail', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityBusinessPhoneColumn(columnIndex) { - return { - title: 'Signing Authority Business Phone', - name: 'signingAuthorityBusinessPhone', - data: 'signingAuthorityBusinessPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getSigningAuthorityCellPhoneColumn(columnIndex) { - return { - title: 'Signing Authority Cell Phone', - name: 'signingAuthorityCellPhone', - data: 'signingAuthorityCellPhone', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getPlaceColumn(columnIndex) { - return { - title: 'Place', - name: 'place', - data: 'place', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getRiskRankingColumn(columnIndex) { - return { - title: 'Risk Ranking', - name: 'riskranking', - data: 'riskRanking', - className: 'data-table-header', - render: function (data) { - return titleCase(data ?? '') ?? ''; - }, - index: columnIndex - }; - } - - function getNotesColumn(columnIndex) { - return { - title: 'Notes', - name: 'notes', - data: 'notes', - className: 'data-table-header multi-line', - width: "20rem", - createdCell: function (td) { - $(td).css('min-width', '20rem'); - }, - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getRedStopColumn(columnIndex) { - return { - title: 'Red-Stop', - name: 'redstop', - data: 'applicant.redStop', - className: 'data-table-header', - render: function (data) { - return convertToYesNo(data); - }, - index: columnIndex - }; - } - - function getIndigenousColumn(columnIndex) { - return { - title: 'Indigenous', - name: 'indigenous', - data: 'applicant.indigenousOrgInd', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getFyeDayColumn(columnIndex) { - return { - title: 'FYE Day', - name: 'fyeDay', - data: 'applicant.fiscalDay', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getFyeMonthColumn(columnIndex) { - return { - title: 'FYE Month', - name: 'fyeMonth', - data: 'applicant.fiscalMonth', - className: 'data-table-header', - render: function (data) { - if (data) { - return titleCase(data); - } - else { - return ''; - } - }, - index: columnIndex - }; - } - - function getApplicantIdColumn(columnIndex) { - return { - title: 'Applicant Id', - name: 'applicantId', - data: 'applicant.unityApplicantId', - className: 'data-table-header', - render: function (data) { - return data ?? ''; - }, - index: columnIndex - }; - } - - function getPayoutColumn(columnIndex) { - return { - title: 'Payout', - name: 'paymentInfo', - data: 'paymentInfo', - className: 'data-table-header', - render: function (data) { - return payoutDefinition(data?.approvedAmount ?? 0, data?.totalPaid ?? 0); - }, - index: columnIndex - }; - } - - // Helper functions - function titleCase(str) { - if (!str) return ''; - str = str.toLowerCase().split(' '); - for (let i = 0; i < str.length; i++) { - str[i] = str[i].charAt(0).toUpperCase() + str[i].slice(1); - } - return str.join(' '); - } - - function convertToYesNo(str) { - switch (str) { - case true: - return "Yes"; - case false: - return "No"; - default: - return ''; - } - } - - function getFullType(code) { - const companyTypes = [ - { code: "BC", name: "BC Company" }, - { code: "CP", name: "Cooperative" }, - { code: "GP", name: "General Partnership" }, - { code: "S", name: "Society" }, - { code: "SP", name: "Sole Proprietorship" }, - { code: "A", name: "Extraprovincial Company" }, - { code: "B", name: "Extraprovincial" }, - { code: "BEN", name: "Benefit Company" }, - { code: "C", name: "Continuation In" }, - { code: "CC", name: "BC Community Contribution Company" }, - { code: "CS", name: "Continued In Society" }, - { code: "CUL", name: "Continuation In as a BC ULC" }, - { code: "EPR", name: "Extraprovincial Registration" }, - { code: "FI", name: "Financial Institution" }, - { code: "FOR", name: "Foreign Registration" }, - { code: "LIB", name: "Public Library Association" }, - { code: "LIC", name: "Licensed (Extra-Pro)" }, - { code: "LL", name: "Limited Liability Partnership" }, - { code: "LLC", name: "Limited Liability Company" }, - { code: "LP", name: "Limited Partnership" }, - { code: "MF", name: "Miscellaneous Firm" }, - { code: "PA", name: "Private Act" }, - { code: "PAR", name: "Parish" }, - { code: "QA", name: "CO 1860" }, - { code: "QB", name: "CO 1862" }, - { code: "QC", name: "CO 1878" }, - { code: "QD", name: "CO 1890" }, - { code: "QE", name: "CO 1897" }, - { code: "REG", name: "Registraton (Extra-pro)" }, - { code: "ULC", name: "BC Unlimited Liability Company" }, - { code: "XCP", name: "Extraprovincial Cooperative" }, - { code: "XL", name: "Extrapro Limited Liability Partnership" }, - { code: "XP", name: "Extraprovincial Limited Partnership" }, - { code: "XS", name: "Extraprovincial Society" } - ]; - const match = companyTypes.find(entry => entry.code === code); - return match ? match.name : "Unknown"; - } - - function payoutDefinition(approvedAmount, totalPaid) { - if ((approvedAmount > 0 && totalPaid > 0) && (approvedAmount === totalPaid)) { - return 'Fully Paid'; - } else if (totalPaid === 0) { - return ''; - } else { - return 'Partially Paid'; - } - } - - function getNames(data) { - let name = ''; - data.forEach((d, index) => { - name = name + (' ' + d.fullName + getDutyText(d)); - if (index != (data.length - 1)) { - name = name + ','; - } - }); - - return name; - } - - function getDutyText(data) { - return data.duty ? (" [" + data.duty + "]") : ''; - } -}); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs index ffa695d942..66d935d0c2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ApplicantsActionBar.cs @@ -31,8 +31,12 @@ public class ApplicantsActionBarWidgetScriptBundleContributor : BundleContributo { public override void ConfigureBundle(BundleConfigurationContext context) { + context.Files + .AddIfNotContains("/Views/Shared/Components/_Shared/string-utils.js"); context.Files .AddIfNotContains("/Views/Shared/Components/ApplicantsActionBar/Default.js"); + context.Files + .AddIfNotContains("/Views/Shared/Components/ApplicantsActionBar/ListMerge.js"); } } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml index aa6dc75e73..52e9348106 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.cshtml @@ -22,8 +22,17 @@ class="custom-table-btn flex-none btn btn-secondary action-bar-btn-unavailable" button-type="Secondary" /> } + @if (await PermissionChecker.IsGrantedAsync(GrantApplicationPermissions.Applicants.Edit)) + { + + } @* Spacer to align buttons properly *@ - \ No newline at end of file + + +@await Html.PartialAsync("~/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml") \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js index 87f04f3461..ef5f2c8064 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/Default.js @@ -43,6 +43,13 @@ $(function () { $('#openApplicant').removeClass('action-bar-btn-unavailable'); } } + + // Show MERGE button only when exactly 2 applicants are selected + if (selectedApplicantIds.length === 2) { + $('#mergeApplicants').removeClass('d-none'); + } else { + $('#mergeApplicants').addClass('d-none'); + } } // Handle OPEN button click @@ -52,6 +59,16 @@ $(function () { } }); + // MERGE button click — open modal with the 2 selected applicants + $('#mergeApplicants').on('click', () => { + if (selectedApplicants.length === 2) { + PubSub.publish('open_applicant_list_merge', { + a: selectedApplicants[0], + b: selectedApplicants[1] + }); + } + }); + // Handle search input $('#search').on('input', function () { let table = $('#ApplicantsTable').DataTable(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml new file mode 100644 index 0000000000..3c3b371e6d --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.cshtml @@ -0,0 +1,84 @@ +
+
Flagged as Duplicated
+
Flagged as Duplicated
+ + + + + + + + + + + + +
+ +
Flagged as Duplicated
+ +
+ +
Flagged as Duplicated
+ +
Principal Record + + + +
+ +
+ +
+ + + +
+
+
Compare Action
+

You are about to merge these applicants. This action cannot be undone.
+ The principal record will be updated with your chosen values.
+ The other record will not be deleted; instead, it will be flagged as a duplicate and can be removed from the Applicant list in a separate process. +

+

Note: The address and contact information for any affected applications will be preserved and remain untouched.

+

Are you sure?

+
+
+ Merging... +
+
Merging, please wait...
+
+
+
+ + +
+
+ + + + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js new file mode 100644 index 0000000000..c4d01da48b --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantsActionBar/ListMerge.js @@ -0,0 +1,186 @@ +(function () { + // Module-level state: the two applicants received when the modal is opened + let _applicantA = null; + let _applicantB = null; + + // Field definitions — label matches ApplicantInfo localization, key is ApplicantListDto camelCase + // The Principal Record row (merge_ApplicantId) is static HTML; these are the dynamic field rows. + const MERGE_FIELDS = [ + { label: 'Applicant Id', key: 'unityApplicantId', radioName: 'merge_UnityApplicantId' }, + { label: 'Applicant Name', key: 'applicantName', radioName: 'merge_ApplicantName' }, + { label: 'Registered Organization Name', key: 'orgName', radioName: 'merge_OrgName' }, + { label: 'Registered Organization Number', key: 'orgNumber', radioName: 'merge_OrgNumber' }, + { label: 'Non-Registered Organization Name', key: 'nonRegOrgName', radioName: 'merge_NonRegOrgName' }, + { label: 'Organization Type', key: 'organizationType', radioName: 'merge_OrganizationType' }, + { label: 'Organization Size', key: 'organizationSize', radioName: 'merge_OrganizationSize' }, + { label: 'Org book status', key: 'orgStatus', radioName: 'merge_OrgStatus' }, + { label: 'Indigenous', key: 'indigenousOrgInd', radioName: 'merge_IndigenousOrgInd' }, + { label: 'Sector', key: 'sector', radioName: 'merge_Sector' }, + { label: 'Sub-sector', key: 'subSector', radioName: 'merge_SubSector' }, + { label: 'Other Sector/Sub/Industry Description', key: 'sectorSubSectorIndustryDesc', radioName: 'merge_SectorSubSectorIndustryDesc' }, + { label: 'Fiscal Year End Day', key: 'fiscalDay', radioName: 'merge_FiscalDay' }, + { label: 'Fiscal Year End Month', key: 'fiscalMonth', radioName: 'merge_FiscalMonth' }, + ]; + + function openListMergeModal(a, b) { + _applicantA = a; + _applicantB = b; + + // Column headers show applicant names + $('#listMergeColA').text(a.applicantName ?? a.id); + $('#listMergeColB').text(b.applicantName ?? b.id); + + // Show "Flagged as Duplicated" badge if the applicant has IsDuplicated=true + $('#listMergeDuplicateFlagA').toggleClass('d-none', !a.isDuplicated); + $('#listMergeDuplicateFlagB').toggleClass('d-none', !b.isDuplicated); + + // Name match summary badge + let score = compareStrings(a.applicantName || '', b.applicantName || ''); + let $badge = $('#listMergeNameMatchBadgeText'); + $badge.removeClass('unity-badge-warning'); + if (score >= 100) { + $badge.text('100% Matched - Possible Duplicate'); + } else if (score >= 50) { + $badge.text('Partially Matched'); + } else { + $badge.text('Not Matched').addClass('unity-badge-warning'); + } + + // Build dynamic field rows + const $tbody = $('#listMergeTableBody').empty(); + MERGE_FIELDS.forEach(f => { + const aVal = a[f.key] ?? ''; + const bVal = b[f.key] ?? ''; + $tbody.append(` + + ${f.label} + + + + + + + `); + }); + + // Reset to step 1 + $('#listMergeStep1').removeClass('d-none'); + $('#listMergeStep2').addClass('d-none'); + + $('#applicantListMergeModal').modal('show'); + } + + $(function () { + PubSub.subscribe('open_applicant_list_merge', (msg, data) => { + openListMergeModal(data.a, data.b); + }); + + // Select All — covers both the static Principal Record row and all dynamic rows + $('#listMergeSelectAllExisting').on('click', () => { + $('#applicantListMergeModal input[type="radio"][value="a"]').prop('checked', true); + }); + $('#listMergeSelectAllNew').on('click', () => { + $('#applicantListMergeModal input[type="radio"][value="b"]').prop('checked', true); + }); + + // Step navigation + $('#listMergeNextBtn').on('click', () => { + $('#listMergeStep1').addClass('d-none'); + $('#listMergeStep2').removeClass('d-none'); + }); + $('#listMergeBackBtn').on('click', () => { + $('#listMergeStep2').addClass('d-none'); + $('#listMergeStep1').removeClass('d-none'); + }); + + // Execute merge + $('#listMergeMergeBtn').on('click', () => { + const a = _applicantA; + const b = _applicantB; + + // Determine principal from the static merge_ApplicantId radio + const principalChoice = $('input[name="merge_ApplicantId"]:checked').val(); + const principal = principalChoice === 'a' ? a : b; + const nonPrincipal = principalChoice === 'a' ? b : a; + + // Build merged field values from dynamic radio selections + const merged = {}; + MERGE_FIELDS.forEach(f => { + const choice = $(`input[name="${f.radioName}"]:checked`).val(); + merged[f.key] = choice === 'a' ? a[f.key] : b[f.key]; + }); + + // Convert indigenousOrgInd "Yes"/"No"/null → bool?/null for UpdateApplicantSummaryDto + let indigenousOrgIndBool = null; + if (merged['indigenousOrgInd'] === 'Yes') indigenousOrgIndBool = true; + else if (merged['indigenousOrgInd'] === 'No') indigenousOrgIndBool = false; + + // Build payload matching UpdateApplicantSummaryDto property names (camelCase via ABP) + const summaryData = { + applicantName: merged['applicantName'] ?? null, + unityApplicantId: merged['unityApplicantId'] ?? null, + orgName: merged['orgName'] ?? null, + orgNumber: merged['orgNumber'] ?? null, + nonRegOrgName: merged['nonRegOrgName'] ?? null, + organizationType: merged['organizationType'] ?? null, + organizationSize: merged['organizationSize'] ?? null, + orgStatus: merged['orgStatus'] ?? null, + indigenousOrgInd: indigenousOrgIndBool, + sector: merged['sector'] ?? null, + subSector: merged['subSector'] ?? null, + sectorSubSectorIndustryDesc: merged['sectorSubSectorIndustryDesc'] ?? null, + fiscalDay: merged['fiscalDay'] === null ? null : String(merged['fiscalDay']), + fiscalMonth: merged['fiscalMonth'] ?? null, + }; + + const modifiedFields = Object.keys(summaryData); + + $('#listMergeSpinner').removeClass('d-none'); + $('#listMergeMergeBtn').prop('disabled', true); + + // Step 1: mark non-principal as duplicated + $.ajax({ + url: '/api/app/applicant/set-duplicated', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + principalApplicantId: principal.id, + nonPrincipalApplicantId: nonPrincipal.id + }) + }).then(() => { + // Step 2: transfer all non-principal applications to principal + return $.ajax({ + url: '/api/app/applicant/transfer-applicant-applications', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ + principalApplicantId: principal.id, + nonPrincipalApplicantId: nonPrincipal.id + }) + }); + }).then(() => { + // Step 3: update principal's summary fields + return unity.grantManager.applicants.applicant + .partialUpdateApplicantSummary(principal.id, { + modifiedFields: modifiedFields, + data: summaryData + }); + }).then(() => { + $('#applicantListMergeModal').modal('hide'); + PubSub.publish('deselect_applicant', 'reset_data'); + $('#ApplicantsTable').DataTable().ajax.reload(); + abp.notify.success('Applicants merged successfully.'); + }).catch(err => { + console.warn('Merge failed:', err); + abp.notify.error('Merge failed. Please try again.'); + }).always(() => { + $('#listMergeSpinner').addClass('d-none'); + $('#listMergeMergeBtn').prop('disabled', false); + }); + }); + }); +})(); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js index dcaeb4b1a4..12cba200c4 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationLinksWidget/Default.js @@ -1,12 +1,12 @@ // Simple HTML escape utility to prevent XSS function escapeHtml(str) { return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(/\//g, '/'); + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('/', '/'); } // Format version number to VX.0 format @@ -321,7 +321,7 @@ $(function () { const errorMessages = response.errorMessages || {}; const activeValidationErrors = this.filterActiveErrors(state); - state.hasValidationErrors = Object.values(activeValidationErrors).some(v => v); + state.hasValidationErrors = Object.values(activeValidationErrors).some(Boolean); this.updateLinkValidationStates(state, errorMessages); @@ -398,8 +398,11 @@ $(function () { const summaryContainer = $('#validationSummaryContainer'); if (state.hasValidationErrors) { - const errorCount = Object.values(state.validationErrors).filter(v => v).length; - if (!summaryContainer.length) { + const errorCount = Object.values(state.validationErrors).filter(Boolean).length; + if (summaryContainer.length) { + $('#validationErrorCount').text(errorCount); + summaryContainer.show(); + } else { const summaryHtml = ` `; $(summaryHtml).insertBefore('.links-display-area'); - } else { - $('#validationErrorCount').text(errorCount); - summaryContainer.show(); } } else if (summaryContainer.length) { summaryContainer.hide(); @@ -629,9 +629,23 @@ $(function () { const inputValue = searchInput.val().trim().toLowerCase(); if (inputValue.length > 0) { - const suggestions = state.suggestionsArray.filter(suggestion => - suggestion.toLowerCase().includes(inputValue) - ); + const suggestions = state.grantApplicationsList + .filter(app => { + const id = (app.UnityApplicantId || '').toString().toLowerCase(); + const refNo = (app.ReferenceNo || '').toLowerCase(); + const applicantName = (app.ApplicantName || '').toLowerCase(); + const orgName = (app.OrganizationName || '').toLowerCase(); + return id.includes(inputValue) + || refNo.includes(inputValue) + || applicantName.includes(inputValue) + || orgName.includes(inputValue); + }) + .map(app => { + + const idPart = app.UnityApplicantId ? ` - (${app.UnityApplicantId})` : ''; + const orgPart = app.OrganizationName ? ` - (${app.OrganizationName})` : ''; + return `Submission #${app.ReferenceNo} - ${app.ApplicantName}${idPart}${orgPart}`; + }); if (suggestions.length > 0) { this.currentSuggestions = suggestions; @@ -738,8 +752,9 @@ $(function () { // Link Selection Service const LinkSelectionService = { selectSuggestion: function(suggestion, state) { - const parts = suggestion.split(' - '); - const referenceNumber = parts[0].trim(); + // Format: "Submission # - " + const match = suggestion.match(/^Submission #(.+?) - /); + const referenceNumber = match ? match[1].trim() : suggestion.split(' - ')[0].trim(); if (this.isDuplicate(referenceNumber, state)) { abp.notify.warn('This application is already linked.'); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml index 058ddd828c..d849163af3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.cshtml @@ -34,7 +34,7 @@
$ + onchange="enableAssessmentResultsSaveBtn(this)" class="unity-currency-input" data-allow-zero="true" disabled="@(!Model.IsPostEditFieldsAllowed_Approval)" />
@@ -150,7 +150,7 @@ diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js index 11a8042d64..bc77bf1c35 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentResults/Default.js @@ -5,10 +5,10 @@ let assessmentResultsCustomForm = $("#assessmentResultsCustomForm").length ? $("#assessmentResultsCustomForm").serializeArray() : []; let combinedData = formData.concat(assessmentResultsCustomForm); let assessmentResultObj = {}; - let formVersionId = $("#ApplicationFormVersionId").val(); + let formVersionId = $("#ApplicationFormVersionId").val(); // Check for worksheet scenario - multiple vs single let multipleWorksheetsIds = $("#AssessmentInfo_WorksheetIds").val(); - let singleWorksheetId = $("#AssessmentInfo_WorksheetId").val(); + let singleWorksheetId = $("#AssessmentInfo_WorksheetId").val(); $.each(combinedData, function (_, input) { if (typeof Flex === 'function' && Flex?.isCustomField(input)) { @@ -50,7 +50,7 @@ try { assessmentResultObj['correlationId'] = formVersionId; - + // Set correct payload property based on worksheet scenario if (multipleWorksheetsIds) { // Multiple worksheets scenario - send as WorksheetIds array @@ -59,10 +59,7 @@ // Single worksheet scenario - send as WorksheetId assessmentResultObj['worksheetId'] = singleWorksheetId.trim(); } - - if(assessmentResultObj['ApprovedAmount'] == '') { - assessmentResultObj['ApprovedAmount'] = null; - } + unity.grantManager.grantApplications.grantApplication .updateAssessmentResults(applicationId, assessmentResultObj) .done(function () { @@ -101,7 +98,7 @@ 'AssessmentResultsView.RequestedAmount', 'AssessmentResultsView.TotalProjectBudget', 'AssessmentResultsView.RecommendedAmount', - 'AssessmentResultsView.ApprovedAmount']; + 'ApprovalView.ApprovedAmount']; return currencyFields.includes(input.name); } @@ -134,7 +131,7 @@ ); PubSub.subscribe('project_info_saved', - (msg, data) => { + (msg, data) => { if (data.RequestedAmount) { $('#RequestedAmountInputAR')?.prop("value", data?.RequestedAmount); $('#RequestedAmountInputAR').maskMoney('mask'); @@ -142,7 +139,7 @@ if (data.TotalProjectBudget) { $('#TotalBudgetInputAR')?.prop("value", data?.TotalProjectBudget); $('#TotalBudgetInputAR').maskMoney('mask'); - } + } } ); @@ -153,7 +150,12 @@ } ); - $('.unity-currency-input').maskMoney(); + $('#PaymentApprovalThreshold').rules('add', { + normalizer: function (value) { + return value.replaceAll(',', ''); + } + }); + $('.unity-currency-input').maskMoney({ allowZero: true }); }); let dueDateHasChanged = false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs index bbcab59471..53b88bfd8d 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewComponent.cs @@ -17,6 +17,9 @@ using Unity.GrantManager.AI; using Unity.GrantManager.Applications; using System.Text.Json; +using Unity.AI.Permissions; +using Volo.Abp.Authorization.Permissions; +using Volo.Abp.Features; namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget { @@ -28,7 +31,9 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.AssessmentScoresWidget public class AssessmentScoresWidgetViewComponent(IAssessmentRepository assessmentRepository, IScoresheetRepository scoresheetRepository, IScoresheetInstanceRepository scoresheetInstanceRepository, - IApplicationRepository applicationRepository) : AbpViewComponent + IApplicationRepository applicationRepository, + IFeatureChecker featureChecker, + IPermissionChecker permissionChecker) : AbpViewComponent { public async Task InvokeAsync(Guid assessmentId, Guid currentUserId) { @@ -94,6 +99,9 @@ public async Task InvokeAsync(Guid assessmentId, Guid curr Status = assessment.Status, CurrentUserId = currentUserId, AssessorId = assessment.AssessorId, + IsAIScoringEnabled = await featureChecker.IsEnabledAsync("Unity.AI.Scoring") && + await permissionChecker.IsGrantedAsync(AIPermissions.ScoringAssistant.ScoringAssistantDefault), + IsAiAssessment = assessment.IsAiAssessment, }; return View(model); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs index 4a74d0c5b3..a2f595173b 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/AssessmentScoresWidgetViewModel.cs @@ -25,6 +25,8 @@ public class AssessmentScoresWidgetViewModel public Guid CurrentUserId { get; set; } public Guid AssessorId { get; set; } public ScoresheetDto? Scoresheet { get; set; } + public bool IsAIScoringEnabled { get; set; } + public bool IsAiAssessment { get; set; } public bool IsDisabled() { if(CurrentUserId != AssessorId) diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml index ab766c0069..6fcb6be0be 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.cshtml @@ -22,12 +22,15 @@
Assessment Scores
- + @if (Model.IsAIScoringEnabled && Model.IsAiAssessment) + { + + } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js index 36d9b92c77..a6de4c4a82 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.js @@ -177,6 +177,7 @@ function deleteDraftEmail(id, rowIndex) { .then(response => { abp.notify.success('Draft email is successfully deleted.', 'Delete Draft Email'); PubSub.publish('refresh_application_emails'); + PubSub.publish('draft_email_deleted', { id: id }); }) .catch(error => { console.error('There was a problem with the fetch operation:', error); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml index 3627fab784..e050edc3ae 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.cshtml @@ -118,6 +118,28 @@ + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index 79fa979834..22f3a14fbc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -38,6 +38,9 @@ let applicationDetails; let mappingConfig; let editorInstance; + let isNewEmailDraft = false; + let newDraftId = null; + let emailAttachmentsTable = null; function bindUIEvents() { UIElements.btnNewEmail.on('click', handleNewEmail); UIElements.btnSend.on('click', handleSendEmail); @@ -110,15 +113,26 @@ e.currentTarget.value = trimmedString; } - function handleCloseEmail() { + function closeEmailFormUI() { $('#modal-content, #modal-background').removeClass('active'); UIElements.emailForm.removeClass('active'); UIElements.btnNewEmail.removeClass('hide'); UIElements.alertEmailReadonly.removeClass('hide'); UIElements.emailForm.trigger("reset"); + $('#email-attachments-section').hide(); enableEmail(); } + function handleCloseEmail() { + if (isNewEmailDraft && newDraftId) { + $.ajax({ url: `/api/app/email-notification/${newDraftId}/email`, type: 'DELETE' }) + .catch(e => console.warn('Failed to delete draft on close:', e)); + isNewEmailDraft = false; + newDraftId = null; + } + closeEmailFormUI(); + } + function handleDiscardEmail() { UIElements.inputEmailTo.val(UIElements.inputOriginalEmailTo.val()); UIElements.inputEmailCC.val(UIElements.inputOriginalEmailCC.val()); @@ -150,21 +164,44 @@ // 2. Clear the underlying