diff --git a/.github/workflows/cypress-dev.yml b/.github/workflows/cypress-dev.yml new file mode 100644 index 0000000000..9aaf565207 --- /dev/null +++ b/.github/workflows/cypress-dev.yml @@ -0,0 +1,34 @@ +name: Cypress E2E — Dev + +# Auto-triggers after a successful dev deployment. +# Can also be run manually — supply base_url to target a feature branch deployment +# instead of the default dev environment URL. +# +# Runs Cypress inside OpenShift (d18498-tools) rather than on the GitHub-hosted +# runner, since the app's Route is IP-allowlisted to the BC Gov network. +# Test credentials come from Vault (GH_UGM_CYPRESS_CONFIG) via External Secrets, +# not GitHub Actions secrets — see cypress-e2e-runner.yml. + +on: + workflow_run: + workflows: ["Dev - Build & Push docker images"] + types: [completed] + workflow_dispatch: + inputs: + base_url: + description: "Override baseUrl (e.g. https://feature-branch.apps.silver.devops.gov.bc.ca/)" + required: false + type: string + +permissions: + contents: read + +jobs: + cypress: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: dev + cypress_config_key: CYPRESS_CONFIG_DEV + base_url: ${{ inputs.base_url || '' }} + secrets: inherit diff --git a/.github/workflows/cypress-e2e-runner.yml b/.github/workflows/cypress-e2e-runner.yml new file mode 100644 index 0000000000..77d7f6fa06 --- /dev/null +++ b/.github/workflows/cypress-e2e-runner.yml @@ -0,0 +1,130 @@ +name: Cypress E2E (runner) + +# Shared job called by cypress-dev/test/uat/prod.yml. +# +# Unlike Grants, this doesn't run Cypress on the GitHub-hosted runner directly — +# the Unity Grant Manager Route is IP-allowlisted to the BC Gov network, which +# GitHub-hosted runners can't reach. Instead this job launches an OpenShift Job +# (from the unity-cypress-job Template in d18498-tools) that runs the existing +# Unity.AutoUI Cypress suite from inside the cluster, waits for it to finish, +# and pulls the logs/screenshots back into this Actions run. +# +# Test credentials are NOT GitHub secrets — the Job pulls them from the +# unity-cypress-config Secret in d18498-tools, synced from Vault +# (GH_UGM_CYPRESS_CONFIG) via External Secrets. +# +# OpenShift auth reuses the same oc-login pattern already used by +# docker-build-dev.yml/docker-build-test.yml/docker-build-main.yml. + +on: + workflow_call: + inputs: + env_name: + description: "Cypress environment name: dev | test | uat | prod" + required: true + type: string + cypress_config_key: + description: "Key in the unity-cypress-config Secret for this env (e.g. CYPRESS_CONFIG_DEV)" + required: true + type: string + base_url: + description: "Optional baseUrl override — use to target a feature branch deployment instead of the default env URL" + required: false + type: string + default: "" + gh_environment: + description: "GitHub Environment to source OpenShift credentials from (defaults to env_name — dev/test have their own; uat/prod reuse 'main')" + required: false + type: string + default: "" + +permissions: + contents: read + +env: + TOOLS_NAMESPACE: d18498-tools + JOB_TIMEOUT: 20m + +jobs: + cypress: + name: Cypress E2E — ${{ inputs.env_name }} + runs-on: ubuntu-latest + environment: ${{ inputs.gh_environment || inputs.env_name }} + env: + OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} + OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + GH_TOKEN: ${{ secrets.GH_API_TOKEN }} + + steps: + - name: Install OpenShift CLI + run: | + curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz + tar -xvf oc.tar.gz + sudo mv oc /usr/local/bin + + - name: Connect to OpenShift API + run: oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + + - name: Launch Cypress Job + id: launch + run: | + # The unity-cypress-job Template lives in the tenant-gitops-d18498 repo + # and is synced into the cluster by ArgoCD — process it by name directly + # from d18498-tools rather than checking out that repo here. + JOB_REF=$(oc process unity-cypress-job \ + -p ENV=${{ inputs.env_name }} \ + -p CYPRESS_CONFIG_KEY=${{ inputs.cypress_config_key }} \ + -p GIT_REF=$GITHUB_SHA \ + -p GIT_TOKEN=$GH_TOKEN \ + -p BASE_URL="${{ inputs.base_url }}" \ + -n $TOOLS_NAMESPACE \ + | oc create -f - -o name -n $TOOLS_NAMESPACE) + echo "Created $JOB_REF" + echo "job_ref=$JOB_REF" >> "$GITHUB_OUTPUT" + + - name: Wait for Cypress Job to finish + id: wait + run: | + JOB_REF="${{ steps.launch.outputs.job_ref }}" + oc wait --for=condition=complete --timeout=$JOB_TIMEOUT "$JOB_REF" -n $TOOLS_NAMESPACE & + COMPLETE_PID=$! + oc wait --for=condition=failed --timeout=$JOB_TIMEOUT "$JOB_REF" -n $TOOLS_NAMESPACE & + FAILED_PID=$! + wait -n $COMPLETE_PID $FAILED_PID || true + kill $COMPLETE_PID $FAILED_PID 2>/dev/null || true + + SUCCEEDED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.succeeded}') + echo "succeeded=${SUCCEEDED:-0}" >> "$GITHUB_OUTPUT" + + - name: Show Cypress logs + if: always() + run: | + JOB_REF="${{ steps.launch.outputs.job_ref }}" + oc logs "$JOB_REF" -n $TOOLS_NAMESPACE -c cypress --tail=-1 || true + + - name: Collect screenshots on failure + if: steps.wait.outputs.succeeded != '1' + run: | + JOB_NAME="${{ steps.launch.outputs.job_ref }}" + JOB_NAME="${JOB_NAME#job.batch/}" + POD=$(oc get pods -n $TOOLS_NAMESPACE -l job-name="$JOB_NAME" -o jsonpath='{.items[0].metadata.name}') + mkdir -p cypress-screenshots + oc cp "$TOOLS_NAMESPACE/$POD:/workspace/applications/Unity.AutoUI/cypress/screenshots" ./cypress-screenshots -c cypress || true + + - name: Upload screenshots on failure + if: steps.wait.outputs.succeeded != '1' + uses: actions/upload-artifact@v5 + with: + name: cypress-screenshots-${{ inputs.env_name }}-${{ github.run_number }} + path: cypress-screenshots + if-no-files-found: ignore + + - name: Clean up Cypress Job + if: always() + run: | + JOB_REF="${{ steps.launch.outputs.job_ref }}" + oc delete "$JOB_REF" -n $TOOLS_NAMESPACE --ignore-not-found + + - name: Fail workflow if Cypress did not succeed + if: steps.wait.outputs.succeeded != '1' + run: exit 1 diff --git a/.github/workflows/cypress-prod.yml b/.github/workflows/cypress-prod.yml new file mode 100644 index 0000000000..03ee93b499 --- /dev/null +++ b/.github/workflows/cypress-prod.yml @@ -0,0 +1,20 @@ +name: Cypress E2E — Prod + +# Manual-only — triggered after UAT sign-off, not automatically after deployment. +# +# Runs Cypress inside OpenShift (d18498-tools) — see cypress-e2e-runner.yml. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + cypress: + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: prod + cypress_config_key: CYPRESS_CONFIG_PROD + gh_environment: main + secrets: inherit diff --git a/.github/workflows/cypress-test.yml b/.github/workflows/cypress-test.yml new file mode 100644 index 0000000000..9a907f33d9 --- /dev/null +++ b/.github/workflows/cypress-test.yml @@ -0,0 +1,24 @@ +name: Cypress E2E — Test + +# Runs against the test environment after a successful test deployment, +# or manually via workflow_dispatch. +# +# Runs Cypress inside OpenShift (d18498-tools) — see cypress-e2e-runner.yml. + +on: + workflow_run: + workflows: ["Test - Build & Push docker images"] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +jobs: + cypress: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: test + cypress_config_key: CYPRESS_CONFIG_TEST + secrets: inherit diff --git a/.github/workflows/cypress-uat.yml b/.github/workflows/cypress-uat.yml new file mode 100644 index 0000000000..4e3236b8f1 --- /dev/null +++ b/.github/workflows/cypress-uat.yml @@ -0,0 +1,30 @@ +name: Cypress E2E — UAT + +# Runs against the UAT environment after a successful main deployment, +# or manually via workflow_dispatch. +# +# Unity has no separate UAT build workflow — UAT deploys off the main build, +# so this triggers off the same workflow_run as prod's build pipeline. +# There's also no "uat" GitHub Environment today, so oc-login reuses the +# "main" Environment's OpenShift credentials (same cluster-wide access). +# +# Runs Cypress inside OpenShift (d18498-tools) — see cypress-e2e-runner.yml. + +on: + workflow_run: + workflows: ["Main - Build & Push docker images"] + types: [completed] + workflow_dispatch: + +permissions: + contents: read + +jobs: + cypress: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/cypress-e2e-runner.yml + with: + env_name: uat + cypress_config_key: CYPRESS_CONFIG_UAT + gh_environment: main + secrets: inherit diff --git a/applications/Unity.AutoUI/.gitignore b/applications/Unity.AutoUI/.gitignore index 2b2e1fdfca..7fa35447c1 100644 --- a/applications/Unity.AutoUI/.gitignore +++ b/applications/Unity.AutoUI/.gitignore @@ -1,8 +1,14 @@ # Cypress TypeScript project gitignore -# Comment cypress.env.json to update build pipeline settings +# Comment cypress.env.json to update build pipeline settings cypress.env.json +# Local environment config files — create from the .example files in cypress/config/ +cypress/config/dev.json +cypress/config/test.json +cypress/config/uat.json +cypress/config/prod.json + # Dependency directories node_modules/ jspm_packages/ diff --git a/applications/Unity.AutoUI/cypress/config/README-gitignore-config-json-files.md b/applications/Unity.AutoUI/cypress/config/README-gitignore-config-json-files.md new file mode 100644 index 0000000000..355f5f08c9 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/README-gitignore-config-json-files.md @@ -0,0 +1,19 @@ +# Cypress Environment Config Files + +The `*.json` config files in this folder are **excluded from git** (via `.gitignore`) because they contain credentials. +Each developer must create their own local copies from the `.example` files provided. + +In CI, `dev.json`/`test.json`/`uat.json`/`prod.json` are written at runtime from the `unity-cypress-config` Secret +(synced from Vault key `GH_UGM_CYPRESS_CONFIG`) by the Cypress Job — see `cypress-job-template.yaml` in +`tenant-gitops-d18498`. + +## Setup + +Copy the example file(s) for the environment(s) you need and fill in your credentials: + +```bash +Copy-Item cypress/config/dev.json.example cypress/config/dev.json +Copy-Item cypress/config/test.json.example cypress/config/test.json +Copy-Item cypress/config/uat.json.example cypress/config/uat.json +Copy-Item cypress/config/prod.json.example cypress/config/prod.json +``` diff --git a/applications/Unity.AutoUI/cypress/config/dev.json.example b/applications/Unity.AutoUI/cypress/config/dev.json.example new file mode 100644 index 0000000000..184c864869 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/dev.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://dev-unity.apps.silver.devops.gov.bc.ca/", + "environment": "DEV", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/dev2.json.example b/applications/Unity.AutoUI/cypress/config/dev2.json.example new file mode 100644 index 0000000000..cf9669d754 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/dev2.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://dev2-unity.apps.silver.devops.gov.bc.ca/", + "environment": "DEV2", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/prod.json.example b/applications/Unity.AutoUI/cypress/config/prod.json.example new file mode 100644 index 0000000000..8daeac885c --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/prod.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://prod-unity.apps.silver.devops.gov.bc.ca/", + "environment": "PROD", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/test.json.example b/applications/Unity.AutoUI/cypress/config/test.json.example new file mode 100644 index 0000000000..0d72840645 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/test.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://test-unity.apps.silver.devops.gov.bc.ca/", + "environment": "TEST", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.AutoUI/cypress/config/uat.json.example b/applications/Unity.AutoUI/cypress/config/uat.json.example new file mode 100644 index 0000000000..3f82b0d2b3 --- /dev/null +++ b/applications/Unity.AutoUI/cypress/config/uat.json.example @@ -0,0 +1,11 @@ +{ + "webapp.url": "https://uat-unity.apps.silver.devops.gov.bc.ca/", + "environment": "UAT", + "test1username": "", + "test1password": "", + "test2username": "", + "test2password": "", + "TEST_EMAIL_TO": "", + "TEST_EMAIL_CC": "", + "TEST_EMAIL_BCC": "" +} diff --git a/applications/Unity.GrantManager/.dockerignore b/applications/Unity.GrantManager/.dockerignore new file mode 100644 index 0000000000..c73fffa5e9 --- /dev/null +++ b/applications/Unity.GrantManager/.dockerignore @@ -0,0 +1,16 @@ +# Local secrets must never end up in a Docker build context / image, +# even for a locally-run `docker build` outside of CI (which never has +# these files in the first place, since they are gitignored). +**/appsettings.secrets.json +**/appsettings.Development.json +**/.env +**/.env.* +**/*.env + +# Build artifacts and local tooling state - not needed in the build +# context and only bloat it / risk copying stale output. +**/bin/ +**/obj/ +**/node_modules/ +.git/ +.vs/ diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs index 0606899c8e..8caf69258d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/IAIService.cs @@ -10,6 +10,7 @@ public interface IAIService Task IsAvailableAsync(); Task GenerateAttachmentSummaryAsync(AttachmentSummaryRequest request, CancellationToken cancellationToken = default); + Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default); Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputDataProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputDataProvider.cs new file mode 100644 index 0000000000..69b3220545 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIApplicationInputDataProvider.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Unity.AI.Models; + +namespace Unity.AI.Operations; + +public interface IAIApplicationInputDataProvider +{ + Task GetApplicationFormAsync(Guid applicationId); + + Task GetApplicationSubmissionAsync(Guid applicationId); + + Task GetApplicationFormVersionAsync(Guid? formVersionId); + + Task> GetAttachmentSummariesAsync(Guid applicationId); + + Task GetScoresheetAsync(Guid scoresheetId); + + Task HasAttachmentsAsync(Guid applicationId); + + Task HasSubmissionAsync(Guid applicationId); +} + +public sealed class ApplicationFormSnapshot +{ + public Guid? ScoresheetId { get; set; } +} + +public sealed class ApplicationSubmissionSnapshot +{ + public Guid? ApplicationFormVersionId { get; set; } + + public string? Submission { get; set; } +} + +public sealed class ApplicationFormVersionSnapshot +{ + public string? FormSchema { get; set; } +} + +public sealed record AttachmentSummarySnapshot( + string? FileName, + string? Summary); + +public sealed class ScoresheetSnapshot +{ + public List Sections { get; set; } = []; +} + +public sealed class ScoresheetSectionSnapshot +{ + public string Name { get; set; } = string.Empty; + + public int Order { get; set; } + + public List Fields { get; set; } = []; +} + +public sealed class ScoresheetFieldSnapshot +{ + public Guid Id { get; set; } + + public string Label { get; set; } = string.Empty; + + public string? Description { get; set; } + + public string Type { get; set; } = string.Empty; + + public int Order { get; set; } + + public string? Definition { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryDataProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryDataProvider.cs new file mode 100644 index 0000000000..aeddf77d69 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryDataProvider.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.AI.Operations; + +public interface IAttachmentSummaryDataProvider +{ + Task GetAttachmentAsync(Guid attachmentId); + + Task UpdateAttachmentSummaryAsync(Guid attachmentId, string summary); + + Task> GetApplicationAttachmentIdsAsync(Guid applicationId); +} + +public sealed record AttachmentSummarySource( + Guid Id, + string? FileName, + string? ChefsSubmissionId, + string? ChefsFileId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryPersistence.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryPersistence.cs new file mode 100644 index 0000000000..e2a803d1d7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAttachmentSummaryPersistence.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.AI.Operations; + +public interface IAttachmentSummaryPersistence +{ + Task LoadAsync(Guid attachmentId); + + Task SaveSummaryAsync(Guid attachmentId, string summary); + + Task> LoadApplicationAttachmentIdsAsync(Guid applicationId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Prompts/UnityPromptAssetManifest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Prompts/UnityPromptAssetManifest.cs new file mode 100644 index 0000000000..93bdb236cf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Prompts/UnityPromptAssetManifest.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Prompts; + +public sealed record UnityPromptAssetManifest( + [property: JsonPropertyName("operationName")] string OperationName, + [property: JsonPropertyName("promptVersion")] string PromptVersion, + [property: JsonPropertyName("inputContractName")] string InputContractName, + [property: JsonPropertyName("outputContractName")] string OutputContractName, + [property: JsonPropertyName("modelHint")] string? ModelHint = null, + [property: JsonPropertyName("profileHint")] string? ProfileHint = null); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs new file mode 100644 index 0000000000..031acd1937 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public sealed class AttachmentSummaryBatchRequest +{ + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = []; + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} + +public sealed class AttachmentSummaryBatchItemRequest +{ + [JsonPropertyName("attachmentId")] + public string AttachmentId { get; set; } = string.Empty; + + [JsonPropertyName("fileName")] + public string FileName { get; set; } = string.Empty; + + [JsonPropertyName("contentType")] + public string ContentType { get; set; } = "application/octet-stream"; + + [JsonPropertyName("extractedText")] + public string? ExtractedText { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs new file mode 100644 index 0000000000..4751fe9122 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public sealed class AttachmentSummaryBatchResponse +{ + [JsonPropertyName("attachments")] + public List Attachments { get; set; } = []; +} + +public sealed class AttachmentSummaryBatchItemResponse +{ + [JsonPropertyName("attachmentId")] + public string AttachmentId { get; set; } = string.Empty; + + [JsonPropertyName("summary")] + public string Summary { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs new file mode 100644 index 0000000000..3a832df03c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs @@ -0,0 +1,14 @@ +using System; + +namespace Unity.AI.Generation; + +public class AIGenerationRequestDto +{ + public Guid ApplicationId { get; set; } + + public Guid OperationId { get; set; } + + public string OperationType { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs new file mode 100644 index 0000000000..ca5684257c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusDto.cs @@ -0,0 +1,12 @@ +namespace Unity.AI.Generation; + +public class AIGenerationStatusDto +{ + public AIGenerationStatusRequestDto? GenerationRequest { get; set; } + + public string? FailureReason { get; set; } + + public bool IsGenerating { get; set; } + + public int RetryAfterSeconds { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs new file mode 100644 index 0000000000..b80e09f433 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Unity.AI.Generation; + +public class AIGenerationStatusRequestDto +{ + public Guid Id { get; set; } + + public Guid? ApplicationId { get; set; } + + public Guid? OperationId { get; set; } + + public string OperationType { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public DateTime? StartedAt { get; set; } + + public DateTime? CompletedAt { get; set; } + + public string? FailureReason { get; set; } + + public bool IsActive { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs index 580d7dc09a..c22118ed39 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/IAIGenerationAppService.cs @@ -15,5 +15,5 @@ public interface IAIGenerationAppService : IApplicationService Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); - Task GenerateContentAsync(Guid applicationId, string? promptVersion = null); + Task GetStatusAsync(Guid applicationId, string operationType); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs index e555345b1c..3e4a2a7b9d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissionDefinitionProvider.cs @@ -10,61 +10,59 @@ public class AIPermissionDefinitionProvider : PermissionDefinitionProvider { public override void Define(IPermissionDefinitionContext context) { - // AI Permission Group - var aiPermissionsGroup = context.AddGroup( - AIPermissions.GroupName, - L("Permission:AI")); + // AI Permission Group + var aiPermissionsGroup = context.AddGroup( + AIPermissions.GroupName, + L("Permission:AI")); + var aiReporting = aiPermissionsGroup.AddPermission( + AIPermissions.Reporting.ReportingDefault, + L("Permission:AI.Reporting")) + .RequireFeatures("Unity.AIReporting"); - var aiReporting = aiPermissionsGroup.AddPermission( - AIPermissions.Reporting.ReportingDefault, - L("Permission:AI.Reporting")) - .RequireFeatures("Unity.AIReporting"); + aiReporting.AddChild( + AIPermissions.Reporting.CreateEditDataModel, + L("Permission:AI.Reporting.CreateEditDataModel")) + .RequireFeatures("Unity.AIReporting"); - aiReporting.AddChild( - AIPermissions.Reporting.CreateEditDataModel, - L("Permission:AI.Reporting.CreateEditDataModel")) - .RequireFeatures("Unity.AIReporting"); + var viewApplicationAnalysis = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewApplicationAnalysis, + L("Permission:AI.ViewApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); - var viewApplicationAnalysis = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewApplicationAnalysis, - L("Permission:AI.ViewApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); + viewApplicationAnalysis.AddChild( + AIPermissions.Analysis.GenerateApplicationAnalysis, + L("Permission:AI.GenerateApplicationAnalysis")) + .RequireFeatures("Unity.AI.ApplicationAnalysis"); - viewApplicationAnalysis.AddChild( - AIPermissions.Analysis.GenerateApplicationAnalysis, - L("Permission:AI.GenerateApplicationAnalysis")) - .RequireFeatures("Unity.AI.ApplicationAnalysis"); + var viewAttachmentSummary = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewAttachmentSummary, + L("Permission:AI.ViewAttachmentSummary")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); - var viewAttachmentSummary = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewAttachmentSummary, - L("Permission:AI.ViewAttachmentSummary")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); + viewAttachmentSummary.AddChild( + AIPermissions.Analysis.GenerateAttachmentSummaries, + L("Permission:AI.GenerateAttachmentSummaries")) + .RequireFeatures("Unity.AI.AttachmentSummaries"); - viewAttachmentSummary.AddChild( - AIPermissions.Analysis.GenerateAttachmentSummaries, - L("Permission:AI.GenerateAttachmentSummaries")) - .RequireFeatures("Unity.AI.AttachmentSummaries"); + var viewScoringResult = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewScoringResult, + L("Permission:AI.ViewScoringResult")) + .RequireFeatures("Unity.AI.Scoring"); - var viewScoringResult = aiPermissionsGroup.AddPermission( - AIPermissions.Analysis.ViewScoringResult, - L("Permission:AI.ViewScoringResult")) - .RequireFeatures("Unity.AI.Scoring"); - - viewScoringResult.AddChild( - AIPermissions.Analysis.GenerateScoring, - L("Permission:AI.GenerateScoring")) - .RequireFeatures("Unity.AI.Scoring"); - - var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); - var configureAI = settingManagement.AddPermission( - AIPermissions.Configuration.ConfigureAI, - L("Permission:AI.ConfigureAI")); - configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( - "Unity.AI.Scoring", - "Unity.AI.AttachmentSummaries", - "Unity.AI.ApplicationAnalysis")); + viewScoringResult.AddChild( + AIPermissions.Analysis.GenerateScoring, + L("Permission:AI.GenerateScoring")) + .RequireFeatures("Unity.AI.Scoring"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); + var configureAI = settingManagement.AddPermission( + AIPermissions.Configuration.ConfigureAI, + L("Permission:AI.ConfigureAI")); + configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( + "Unity.AI.Scoring", + "Unity.AI.AttachmentSummaries", + "Unity.AI.ApplicationAnalysis")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs index 0bdff63a08..d5b673b699 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptDto.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Volo.Abp.Application.Dtos; namespace Unity.AI.Prompts; @@ -7,8 +6,9 @@ namespace Unity.AI.Prompts; public class AIPromptDto : AuditedEntityDto { public string Name { get; set; } = string.Empty; - public string? Description { get; set; } - public PromptType Type { get; set; } + public int VersionNumber { get; set; } + public string SystemPrompt { get; set; } = string.Empty; + public string UserPrompt { get; set; } = string.Empty; + public string? MetadataJson { get; set; } public bool IsActive { get; set; } - public List Versions { get; set; } = new(); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs deleted file mode 100644 index a9ed4e50a8..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/AIPromptVersionDto.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Volo.Abp.Application.Dtos; - -namespace Unity.AI.Prompts; - -public class AIPromptVersionDto : AuditedEntityDto -{ - public Guid PromptId { get; set; } - public int VersionNumber { get; set; } - public string SystemPrompt { get; set; } = string.Empty; - public string UserPromptTemplate { get; set; } = string.Empty; - public string? DeveloperNotes { get; set; } - public string? TargetModel { get; set; } - public string? TargetProvider { get; set; } - public double Temperature { get; set; } - public int? MaxTokens { get; set; } - public bool IsPublished { get; set; } - public bool IsDeprecated { get; set; } - public string? MetadataJson { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs index 6d361fd3ba..e26973030a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptDto.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel; using System.ComponentModel.DataAnnotations; @@ -5,17 +6,21 @@ namespace Unity.AI.Prompts; public class CreateUpdateAIPromptDto { + public Guid PromptId { get; set; } + + [DisplayName("VersionNumber")] + public int VersionNumber { get; set; } + [Required] - [MaxLength(200)] - [DisplayName("PromptName")] - public string Name { get; set; } = string.Empty; + [DisplayName("SystemPrompt")] + public string SystemPrompt { get; set; } = string.Empty; - [MaxLength(2000)] - [DisplayName("PromptDescription")] - public string? Description { get; set; } + [Required] + [DisplayName("UserPrompt")] + public string UserPrompt { get; set; } = string.Empty; - [DisplayName("PromptType")] - public PromptType Type { get; set; } + [DisplayName("MetadataJson")] + public string? MetadataJson { get; set; } [DisplayName("PromptIsActive")] public bool IsActive { get; set; } = true; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs deleted file mode 100644 index 8e3414943f..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/CreateUpdateAIPromptVersionDto.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; - -namespace Unity.AI.Prompts; - -public class CreateUpdateAIPromptVersionDto -{ - public Guid PromptId { get; set; } - - [DisplayName("VersionNumber")] - public int VersionNumber { get; set; } - - [Required] - [DisplayName("SystemPrompt")] - public string SystemPrompt { get; set; } = string.Empty; - - [Required] - [DisplayName("UserPromptTemplate")] - public string UserPromptTemplate { get; set; } = string.Empty; - - [DisplayName("DeveloperNotes")] - public string? DeveloperNotes { get; set; } - - [MaxLength(100)] - [DisplayName("TargetModel")] - public string? TargetModel { get; set; } - - [MaxLength(100)] - [DisplayName("TargetProvider")] - public string? TargetProvider { get; set; } - - [DisplayName("Temperature")] - public double Temperature { get; set; } = 0.2; - - [DisplayName("MaxTokens")] - public int? MaxTokens { get; set; } - - [DisplayName("IsPublished")] - public bool IsPublished { get; set; } - - [DisplayName("IsDeprecated")] - public bool IsDeprecated { get; set; } - - [DisplayName("MetadataJson")] - public string? MetadataJson { get; set; } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs index c259996db0..7aca79853d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptAppService.cs @@ -10,4 +10,5 @@ public interface IAIPromptAppService : ICrudAppService< PagedAndSortedResultRequestDto, CreateUpdateAIPromptDto> { + System.Threading.Tasks.Task> GetByPromptAsync(Guid promptId); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs deleted file mode 100644 index 269029e5ec..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Prompts/IAIPromptVersionAppService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using Volo.Abp.Application.Dtos; -using Volo.Abp.Application.Services; - -namespace Unity.AI.Prompts; - -public interface IAIPromptVersionAppService : ICrudAppService< - AIPromptVersionDto, - Guid, - PagedAndSortedResultRequestDto, - CreateUpdateAIPromptVersionDto> -{ - System.Threading.Tasks.Task> GetByPromptAsync(Guid promptId); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs index 1da60206a2..4733c05dc5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Extraction/TextExtractionService.cs @@ -37,7 +37,6 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string { if (fileContent == null) { - _logger.LogDebug("File content stream is null for {FileName}", fileName); return Task.FromResult(string.Empty); } @@ -49,7 +48,6 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string if (extension == ".doc") { - _logger.LogDebug("Legacy .doc extraction is not supported for {FileName}", fileName); return Task.FromResult(string.Empty); } @@ -63,13 +61,7 @@ public Task ExtractTextAsync(string fileName, Stream fileContent, string _ => ExtractByContentType(fileName, fileContent, normalizedContentType, cancellationToken) }; - if (string.IsNullOrEmpty(rawText)) - { - _logger.LogDebug("No text extraction available for content type {ContentType} with extension {Extension}", - contentType, extension); - } - - return Task.FromResult(NormalizeAndLimitText(rawText, fileName)); + return Task.FromResult(NormalizeAndLimitText(rawText)); } catch (OperationCanceledException) { @@ -138,12 +130,9 @@ private string ExtractTextFromTextFile(Stream fileContent, CancellationToken can builder.Append(buffer, 0, Math.Min(read, remaining)); if (builder.Length >= MaxExtractedTextLength) { - _logger.LogDebug("Truncated text content to {MaxLength} characters", MaxExtractedTextLength); break; } } - - _logger.LogDebug("Extracted {CharacterCount} characters from text-based content.", builder.Length); return builder.ToString(); } catch (Exception ex) @@ -180,7 +169,6 @@ private string ExtractTextFromPdfFile(string fileName, Stream fileContent, Cance } } - _logger.LogDebug("Extracted PDF text from {ProcessedPageCount} pages for {FileName}", processedPageCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -200,11 +188,6 @@ private string ExtractTextFromWordDocx(string fileName, Stream fileContent, Canc var processedParagraphCount = AppendDocxParagraphText(document, builder, cancellationToken); var processedTableRowCount = AppendDocxTableText(document, builder, cancellationToken); - _logger.LogDebug( - "Extracted Word text from {ProcessedParagraphCount} paragraphs and {ProcessedTableRowCount} table rows for {FileName}", - processedParagraphCount, - processedTableRowCount, - fileName); return builder.ToString(); } catch (Exception ex) @@ -324,11 +307,6 @@ private string ExtractTextFromExcelFile(string fileName, Stream fileContent, Can } } - _logger.LogDebug( - "Extracted Excel text from {ProcessedSheetCount} sheets and {ProcessedRowCount} rows for {FileName}", - processedSheetCount, - processedRowCount, - fileName); return builder.ToString(); } catch (Exception ex) @@ -371,7 +349,6 @@ private string ExtractTextFromPowerPointFile(string fileName, Stream fileContent } } - _logger.LogDebug("Extracted PowerPoint text from {ProcessedSlideCount} slides for {FileName}", processedSlideCount, fileName); return builder.ToString(); } catch (Exception ex) @@ -390,14 +367,12 @@ private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive 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(); @@ -417,8 +392,6 @@ private IEnumerable GetOrderedPowerPointSlideEntries(ZipArchive { orderedEntries.AddRange(slideEntriesByName.Values.OrderBy(entry => GetPowerPointSlideNumber(entry.FullName))); } - - _logger.LogDebug("Resolved PowerPoint presentation order for {SlideCount} slides.", orderedEntries.Count); return orderedEntries; } @@ -583,7 +556,7 @@ private List TryGetPowerPointSlideOrder(ZipArchive archive) } catch (Exception ex) { - _logger.LogDebug(ex, "Falling back to part-name slide order for PowerPoint extraction."); + _logger.LogDebug(ex, "Could not determine PowerPoint slide order from relationships; falling back to part-name order."); return new List(); } } @@ -683,15 +656,13 @@ private static string GetCellText(NPOI.SS.UserModel.ICell cell) }) ?? string.Empty; } - private string NormalizeAndLimitText(string text, string fileName) + private string NormalizeAndLimitText(string text) { var normalized = NormalizeExtractedText(text); - normalized = RemoveLeadingFileNameArtifact(normalized, fileName); if (normalized.Length > MaxExtractedTextLength) { normalized = normalized.Substring(0, MaxExtractedTextLength); - _logger.LogDebug("Truncated extracted content to {MaxLength} characters", MaxExtractedTextLength); } return normalized; @@ -709,11 +680,6 @@ private static string NormalizeExtractedText(string text) .Replace("\r\n", "\n") .Replace('\r', '\n'); - normalized = LowerToUpperWordBoundaryRegex().Replace(normalized, " "); - normalized = PunctuationToWordBoundaryRegex().Replace(normalized, " "); - normalized = ColonDashSpacingRegex().Replace(normalized, ": - "); - normalized = HyphenSpacingRegex().Replace(normalized, " - "); - normalized = KeywordBoundaryRegex().Replace(normalized, " "); normalized = MultipleSpacesRegex().Replace(normalized, " "); normalized = NewlineWhitespaceRegex().Replace(normalized, "\n"); normalized = MultipleNewlinesRegex().Replace(normalized, "\n"); @@ -721,55 +687,6 @@ private static string NormalizeExtractedText(string text) return normalized.Trim(); } - private static string RemoveLeadingFileNameArtifact(string text, string fileName) - { - if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(fileName)) - { - return text; - } - - var rawStem = Path.GetFileNameWithoutExtension(fileName)?.Trim(); - if (string.IsNullOrWhiteSpace(rawStem)) - { - return text; - } - - var decodedStem = Uri.UnescapeDataString(rawStem); - foreach (var candidate in new[] { rawStem, decodedStem }) - { - if (string.IsNullOrWhiteSpace(candidate)) - { - continue; - } - - if (text.StartsWith(candidate, StringComparison.OrdinalIgnoreCase)) - { - var stripped = text.Substring(candidate.Length).TrimStart(' ', '-', ':', '.', '\t'); - if (!string.IsNullOrWhiteSpace(stripped)) - { - return stripped; - } - } - } - - return text; - } - - [GeneratedRegex(@"(?<=[a-z])(?=[A-Z])")] - private static partial Regex LowerToUpperWordBoundaryRegex(); - - [GeneratedRegex(@"(?<=[\.\,\:\;\)])(?=[A-Za-z0-9])")] - private static partial Regex PunctuationToWordBoundaryRegex(); - - [GeneratedRegex(@":-")] - private static partial Regex ColonDashSpacingRegex(); - - [GeneratedRegex(@"(?<=\S)- (?=[A-Za-z])")] - private static partial Regex HyphenSpacingRegex(); - - [GeneratedRegex(@"(?<=[a-z])(?=(project|funding|budget|community|summary|notes|details|planning|outcomes|background|services)\b)", RegexOptions.IgnoreCase)] - private static partial Regex KeywordBoundaryRegex(); - [GeneratedRegex(@"[ \t]+")] private static partial Regex MultipleSpacesRegex(); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIApplicationInputBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIApplicationInputBuilder.cs index b37897ef6b..1378b5bbd5 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIApplicationInputBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIApplicationInputBuilder.cs @@ -6,25 +6,19 @@ using System.Threading.Tasks; using Unity.AI.Models; using Unity.AI.Prompts; -using Unity.Flex.Domain.Scoresheets; -using Unity.GrantManager.Applications; using Volo.Abp; using Volo.Abp.DependencyInjection; namespace Unity.AI.Operations; public class AIApplicationInputBuilder( - IApplicationFormRepository applicationFormRepository, - IApplicationFormSubmissionRepository applicationFormSubmissionRepository, - IApplicationFormVersionRepository applicationFormVersionRepository, - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, - IScoresheetRepository scoresheetRepository, + IAIApplicationInputDataProvider dataProvider, ILogger logger) : IAIApplicationInputBuilder, ITransientDependency { public async Task BuildApplicationAnalysisInputAsync(AIApplicationPromptDataDto application, string? promptVersion) { - var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(application.ApplicationId); - var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == application.ApplicationId); + var formSubmission = await dataProvider.GetApplicationSubmissionAsync(application.ApplicationId); + var attachments = PromptDataPayloadBuilder.BuildAttachmentSummaries(await dataProvider.GetAttachmentSummariesAsync(application.ApplicationId)); var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); return new ApplicationAnalysisOperationInputDto @@ -32,29 +26,29 @@ public async Task BuildApplicationAnalysis ApplicationId = application.ApplicationId, Schema = JsonSerializer.SerializeToElement(PromptDataPayloadBuilder.BuildFormFieldConfiguration(formSchema, logger)), Data = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission?.Submission, formSchema, logger), - Attachments = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments), + Attachments = attachments, PromptVersion = promptVersion }; } public async Task BuildApplicationScoringInputAsync(AIApplicationPromptDataDto application, string? promptVersion) { - var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); - if (applicationForm.ScoresheetId == null) + var applicationForm = await dataProvider.GetApplicationFormAsync(application.ApplicationId); + if (applicationForm?.ScoresheetId == null) { throw new UserFriendlyException("Scoring requires a configured scoresheet."); } - var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); + var scoresheet = await dataProvider.GetScoresheetAsync(applicationForm.ScoresheetId.Value); if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) { throw new UserFriendlyException("Scoring requires a scoresheet with fields."); } - var attachments = await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == application.ApplicationId); + var attachments = await dataProvider.GetAttachmentSummariesAsync(application.ApplicationId); var attachmentSummaries = PromptDataPayloadBuilder.BuildAttachmentSummaries(attachments); - var formSubmission = await applicationFormSubmissionRepository.GetByApplicationAsync(application.ApplicationId); + var formSubmission = await dataProvider.GetApplicationSubmissionAsync(application.ApplicationId); var formSchema = await GetFormSchemaAsync(formSubmission?.ApplicationFormVersionId); var promptData = PromptDataPayloadBuilder.BuildPromptDataPayload(application, formSubmission?.Submission, formSchema, logger); @@ -86,7 +80,7 @@ public async Task BuildApplicationScoringIn try { - var formVersion = await applicationFormVersionRepository.GetAsync(formVersionId.Value); + var formVersion = await dataProvider.GetApplicationFormVersionAsync(formVersionId); return string.IsNullOrWhiteSpace(formVersion?.FormSchema) ? null : formVersion.FormSchema; } catch (Exception ex) @@ -96,7 +90,7 @@ public async Task BuildApplicationScoringIn } } - private static List BuildSectionQuestionsData(ScoresheetSection section) + private static List BuildSectionQuestionsData(ScoresheetSectionSnapshot section) { var sectionQuestionsData = new List(); foreach (var field in section.Fields.OrderBy(f => f.Order)) @@ -117,9 +111,9 @@ private static List BuildSectionQuestionsData(ScoresheetSection section) return sectionQuestionsData; } - private static object[]? ExtractSelectListOptions(Question field) + private static object[]? ExtractSelectListOptions(ScoresheetFieldSnapshot field) { - if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList || string.IsNullOrEmpty(field.Definition)) + if (field.Type != Unity.Flex.Scoresheets.Enums.QuestionType.SelectList.ToString() || string.IsNullOrEmpty(field.Definition)) { return null; } @@ -141,7 +135,6 @@ private static List BuildSectionQuestionsData(ScoresheetSection section) } catch (JsonException) { - // Ignore malformed definition and return null options. } return null; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs index 96078d8970..c6465ac29b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionStrategy.cs @@ -28,19 +28,21 @@ public static async Task> RunAsync( switch (mode) { - case AIExecutionMode.Parallel: - return [.. await Task.WhenAll(items.Select(operation))]; - - case AIExecutionMode.Batch: - return await batchOperation(items); - - default: + case AIExecutionMode.Sequential: var sequential = new List(items.Count); foreach (var item in items) { sequential.Add(await operation(item)); } return sequential; + + case AIExecutionMode.Batch: + return await batchOperation(items); + + case AIExecutionMode.Parallel: + return [.. await Task.WhenAll(items.Select(operation))]; } + + throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unsupported AI execution mode."); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs index 50b4217195..eb882c8cae 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIGenerationPrerequisiteValidator.cs @@ -2,29 +2,19 @@ using System; using System.Linq; using System.Threading.Tasks; -using Unity.Flex.Domain.Scoresheets; using Unity.AI.Localization; -using Unity.GrantManager.Applications; using Volo.Abp; using Volo.Abp.DependencyInjection; -using Volo.Abp.Linq; namespace Unity.AI.Operations; public class AIGenerationPrerequisiteValidator( - IApplicationRepository applicationRepository, - IApplicationFormRepository applicationFormRepository, - IApplicationFormSubmissionRepository applicationFormSubmissionRepository, - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, - IScoresheetRepository scoresheetRepository, - IAsyncQueryableExecuter asyncExecuter, + IAIApplicationInputDataProvider dataProvider, IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency { public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) { - var attachmentQuery = await applicationChefsFileAttachmentRepository.GetQueryableAsync(); - var hasAttachments = await asyncExecuter.AnyAsync(attachmentQuery.Where(a => a.ApplicationId == applicationId)); - if (!hasAttachments) + if (!await dataProvider.HasAttachmentsAsync(applicationId)) { throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]); } @@ -32,8 +22,7 @@ public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) { - var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); - if (submission == null || string.IsNullOrWhiteSpace(submission.Submission)) + if (!await dataProvider.HasSubmissionAsync(applicationId)) { throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]); } @@ -41,14 +30,13 @@ public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId) { - var application = await applicationRepository.GetAsync(applicationId); - var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); - if (applicationForm.ScoresheetId == null) + var applicationForm = await dataProvider.GetApplicationFormAsync(applicationId); + if (applicationForm?.ScoresheetId == null) { throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]); } - var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); + var scoresheet = await dataProvider.GetScoresheetAsync(applicationForm.ScoresheetId.Value); if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) { throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs new file mode 100644 index 0000000000..5b7cfa2bae --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryPersistence.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Unity.GrantManager.Applications; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; + +namespace Unity.AI.Operations; + +public class AttachmentSummaryPersistence( + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IUnitOfWorkManager unitOfWorkManager) : IAttachmentSummaryPersistence, ITransientDependency +{ + public async Task LoadAsync(Guid attachmentId) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); + var source = new AttachmentSummarySource( + attachment.Id, + attachment.FileName, + attachment.ChefsSubmissionId, + attachment.ChefsFileId); + await uow.CompleteAsync(); + return source; + } + + public async Task SaveSummaryAsync(Guid attachmentId, string summary) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true); + var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); + attachment.AISummary = summary; + await applicationChefsFileAttachmentRepository.UpdateAsync(attachment); + await uow.CompleteAsync(); + } + + public async Task> LoadApplicationAttachmentIdsAsync(Guid applicationId) + { + using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); + var ids = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) + .Select(a => a.Id) + .ToList(); + await uow.CompleteAsync(); + return ids; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs index 7b3a2e562f..0f5a437289 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AttachmentSummaryService.cs @@ -1,5 +1,5 @@ -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.IO; @@ -9,7 +9,6 @@ using Unity.AI.Extraction; using Unity.AI.Localization; using Unity.AI.Requests; -using Unity.GrantManager.Applications; using Unity.GrantManager.Intakes; using Volo.Abp; using Volo.Abp.DependencyInjection; @@ -18,7 +17,7 @@ namespace Unity.AI.Operations; public class AttachmentSummaryService( - IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IAttachmentSummaryDataProvider attachmentSummaryDataProvider, IChefsFileAttachmentStreamProvider chefsFileAttachmentStreamProvider, ITextExtractionService textExtractionService, IAIService aiService, @@ -67,6 +66,11 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen } var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); + if (mode == AIExecutionMode.Batch) + { + return await GenerateBatchAsync(ids, promptVersion, cancellationToken); + } + if (mode != AIExecutionMode.Sequential) { logger.LogWarning( @@ -82,6 +86,91 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen batch => GenerateSequentiallyAsync(batch, promptVersion, cancellationToken)); } + private async Task> GenerateBatchAsync( + IReadOnlyCollection attachmentIds, + string? promptVersion, + CancellationToken cancellationToken) + { + var attachments = new List<(Guid Id, AttachmentSummarySource Source, string ContentType, string ExtractedText)>(attachmentIds.Count); + var failures = new Dictionary(); + + foreach (var attachmentId in attachmentIds) + { + try + { + var attachment = await LoadAttachmentAsync(attachmentId); + var fileName = string.IsNullOrWhiteSpace(attachment.FileName) ? "unknown" : attachment.FileName; + await using var attachmentStream = await OpenAttachmentStreamAsync(attachment, fileName, cancellationToken); + var extractedText = await textExtractionService.ExtractTextAsync(fileName, attachmentStream.Content, attachmentStream.ContentType, cancellationToken); + if (ShouldStopOnEmptyExtraction(fileName, extractedText)) + { + LogEmptyExtraction(attachmentId, fileName, attachmentStream); + failures[attachmentId] = TextExtractionFailedSummary; + continue; + } + + attachments.Add((attachmentId, attachment, attachmentStream.ContentType, extractedText)); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Error preparing AI summary batch item {AttachmentId}", attachmentId); + failures[attachmentId] = SummaryGenerationFailedMessage; + } + } + + if (attachments.Count == 0) + { + return attachmentIds.Select(id => failures.TryGetValue(id, out var failure) ? failure : SummaryGenerationFailedMessage).ToList(); + } + + var batchRequest = new AttachmentSummaryBatchRequest + { + PromptVersion = promptVersion, + Attachments = attachments.Select(item => new AttachmentSummaryBatchItemRequest + { + AttachmentId = item.Id.ToString(), + FileName = string.IsNullOrWhiteSpace(item.Source.FileName) ? "unknown" : item.Source.FileName!, + ContentType = item.ContentType, + ExtractedText = item.ExtractedText + }).ToList() + }; + + var batchResponse = await aiService.GenerateAttachmentSummaryBatchAsync(batchRequest, cancellationToken); + var responseMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in batchResponse.Attachments) + { + if (!string.IsNullOrWhiteSpace(item.AttachmentId)) + { + responseMap[item.AttachmentId] = item.Summary; + } + } + + var results = new List(attachmentIds.Count); + foreach (var attachmentId in attachmentIds) + { + if (failures.TryGetValue(attachmentId, out var failure)) + { + results.Add(failure); + continue; + } + + if (responseMap.TryGetValue(attachmentId.ToString(), out var summary)) + { + await SaveSummaryAsync(attachmentId, summary); + results.Add(summary); + continue; + } + + results.Add(SummaryGenerationFailedMessage); + } + + return results; + } + private async Task> GenerateSequentiallyAsync( IReadOnlyCollection attachmentIds, string? promptVersion, @@ -144,34 +233,18 @@ public async Task> GenerateForApplicationAsync( private async Task LoadAttachmentAsync(Guid attachmentId) { - using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); - var source = new AttachmentSummarySource( - attachment.Id, - attachment.FileName, - attachment.ChefsSubmissionId, - attachment.ChefsFileId); - await uow.CompleteAsync(); - return source; + var attachment = await attachmentSummaryDataProvider.GetAttachmentAsync(attachmentId); + return attachment ?? throw new UserFriendlyException(localizer[AILocalizationKeys.AttachmentNotFound]); } private async Task SaveSummaryAsync(Guid attachmentId, string summary) { - using var uow = unitOfWorkManager.Begin(requiresNew: true); - var attachment = await applicationChefsFileAttachmentRepository.GetAsync(attachmentId); - attachment.AISummary = summary; - await applicationChefsFileAttachmentRepository.UpdateAsync(attachment); - await uow.CompleteAsync(); + await attachmentSummaryDataProvider.UpdateAttachmentSummaryAsync(attachmentId, summary); } private async Task> LoadApplicationAttachmentIdsAsync(Guid applicationId) { - using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false); - var ids = (await applicationChefsFileAttachmentRepository.GetListAsync(a => a.ApplicationId == applicationId)) - .Select(a => a.Id) - .ToList(); - await uow.CompleteAsync(); - return ids; + return await attachmentSummaryDataProvider.GetApplicationAttachmentIdsAsync(applicationId); } private async Task WithUnitOfWorkAsync(Func operation) @@ -256,10 +329,4 @@ private void LogEmptyExtraction( return null; } } - - private sealed record AttachmentSummarySource( - Guid Id, - string? FileName, - string? ChefsSubmissionId, - string? ChefsFileId); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs index fbcd721bf5..cb769ece99 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/PromptDataPayloadBuilder.cs @@ -6,7 +6,6 @@ using System.Text.Json; using Unity.AI.Operations; using Unity.AI.Models; -using Unity.GrantManager.Applications; namespace Unity.AI.Prompts { @@ -76,17 +75,14 @@ public static JsonElement BuildPromptDataPayload( } public static List BuildAttachmentSummaries( - IEnumerable attachments, - bool excludeWhitespaceOnlySummaries = true) + IEnumerable attachments) { return attachments - .Where(a => excludeWhitespaceOnlySummaries - ? !string.IsNullOrWhiteSpace(a.AISummary) - : !string.IsNullOrEmpty(a.AISummary)) + .Where(a => !string.IsNullOrWhiteSpace(a.Summary)) .Select(a => new AIAttachmentItem { Name = string.IsNullOrWhiteSpace(a.FileName) ? "attachment" : a.FileName.Trim(), - Summary = a.AISummary!.Trim() + Summary = a.Summary!.Trim() }) .ToList(); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md deleted file mode 100644 index 0f6146d2ab..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# Runtime Prompt Templates - -Runtime prompts are now resolved from the database-backed `AIPrompts` and `AIPromptVersions` records seeded by the AI module. -These files are retained as prompt asset references and seed inputs, not as the runtime source of truth. - -Current prompt asset references: - -- `application-analysis.system.txt` -- `application-analysis.user.txt` -- `application-analysis.rubric.txt` (optional, when `{{RUBRIC}}` is used) -- `application-analysis.score.txt` (optional, when `{{SCORE}}` is used) -- `application-analysis.output.txt` (optional, when `{{OUTPUT}}` is used) -- `application-analysis.rules.txt` (optional, when `{{RULES}}` is used) -- `common.*.txt` (optional shared fragments for `{{COMMON_*}}` placeholders) -- `attachment-summary.system.txt` -- `attachment-summary.user.txt` -- `attachment-summary.output.txt` (optional, when `{{OUTPUT}}` is used) -- `attachment-summary.rules.txt` (optional, when `{{RULES}}` is used) -- `application-scoring.system.txt` -- `application-scoring.user.txt` -- `application-scoring.output.txt` (optional, when `{{OUTPUT}}` is used) -- `application-scoring.rules.txt` (optional, when `{{RULES}}` is used) - -Placeholders: - -- `{{SCHEMA}}` -- `{{DATA}}` -- `{{ATTACHMENTS}}` -- `{{RUBRIC}}` -- `{{SCORE}}` -- `{{OUTPUT}}` -- `{{RULES}}` -- `{{ATTACHMENT}}` -- `{{DATA}}` -- `{{ATTACHMENTS}}` -- `{{SECTION}}` -- `{{RESPONSE}}` - -Version selection: - -- Required: `Azure:Operations:Defaults:PromptVersion = v0|v1`, with optional overrides under `Azure:Operations::PromptVersion`. -- Unknown or missing version values fail at runtime. - -Template loading is strict: - -- Core prompt records are required for each version. -- Missing required prompt records fail fast at runtime with a configuration error. -- Runtime prompt rendering resolves placeholders from the stored template text plus the version metadata sections. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/attachment-summary.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/attachment-summary.user.txt index 500fc5ec7a..98da3a726e 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/attachment-summary.user.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v0/attachment-summary.user.txt @@ -1,2 +1,2 @@ -ATTACHMENT -{{ATTACHMENT}} +ATTACHMENTS +{{ATTACHMENTS}} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt index 5840d215c8..f1627fb26f 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/application-analysis.rules.txt @@ -22,7 +22,8 @@ - Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. -- If no findings exist, return empty arrays. +- Errors and warnings may be empty. +- Summaries and recommendations must each include at least one item. - Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/attachment-summary.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/attachment-summary.user.txt index acf4c67f3e..13cf1e6773 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/attachment-summary.user.txt +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v1/attachment-summary.user.txt @@ -1,5 +1,5 @@ -ATTACHMENT -{{ATTACHMENT}} +ATTACHMENTS +{{ATTACHMENTS}} RESPONSE {{RESPONSE}} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs index de92cd64b7..68a08cf2e9 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateProvider.cs @@ -1,47 +1,15 @@ -using System; using System.Threading; using System.Threading.Tasks; -using Unity.AI.Domain; using Volo.Abp.DependencyInjection; -using Volo.Abp.Domain.Repositories; -using Volo.Abp.MultiTenancy; namespace Unity.AI.Runtime; public class AIPromptTemplateProvider( - IRepository promptRepository, - IRepository promptVersionRepository, - ICurrentTenant currentTenant) : IAIPromptTemplateProvider, ITransientDependency + IAIPromptTemplateStore promptTemplateStore) : IAIPromptTemplateProvider, ITransientDependency { - public async Task GetRequiredPromptAsync( + public Task GetRequiredPromptAsync( string promptType, string promptVersion, CancellationToken cancellationToken = default) - { - var normalizedPromptVersion = OpenAIPromptRenderer.ResolvePromptVersion(promptVersion); - var versionNumber = OpenAIPromptRenderer.ResolvePromptVersionNumber(normalizedPromptVersion); - - using (currentTenant.Change(null)) - { - var prompt = await promptRepository.FindAsync(p => p.Name == promptType); - if (prompt == null || !prompt.IsActive) - { - throw new InvalidOperationException($"AI prompt '{promptType}' is not configured."); - } - - var version = await promptVersionRepository.FindAsync( - v => v.PromptId == prompt.Id && v.VersionNumber == versionNumber); - if (version == null || !version.IsPublished || version.IsDeprecated) - { - throw new InvalidOperationException( - $"AI prompt version '{normalizedPromptVersion}' for prompt '{promptType}' is not configured."); - } - - return new AIPromptTemplateSnapshot( - normalizedPromptVersion, - version.SystemPrompt, - version.UserPromptTemplate, - version.MetadataJson); - } - } + => promptTemplateStore.GetRequiredPromptAsync(promptType, promptVersion, cancellationToken); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs index b6fc24cd60..e915cad4e8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateRenderer.cs @@ -35,7 +35,22 @@ public static string BuildAttachmentSummaryUserPrompt( metadataJson, new Dictionary { - ["ATTACHMENT"] = attachment + ["ATTACHMENT"] = attachment, + ["ATTACHMENTS"] = attachment + }); + } + + public static string BuildAttachmentSummaryBatchUserPrompt( + string userPromptTemplate, + string attachments, + string? metadataJson = null) + { + return RenderPromptTemplate( + userPromptTemplate, + metadataJson, + new Dictionary + { + ["ATTACHMENTS"] = attachments }); } @@ -116,11 +131,6 @@ private static Dictionary ExtractMetadataSections(string? metada return new Dictionary(StringComparer.Ordinal); } - if (root.TryGetProperty("sections", out var sections) && sections.ValueKind == JsonValueKind.Object) - { - return ExtractStringProperties(sections); - } - return ExtractStringProperties(root); } catch (JsonException ex) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs index fb60ccdde6..115cc229fe 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateSnapshot.cs @@ -1,7 +1,31 @@ +using System; +using System.Text.Json; +using Unity.AI.Prompts; + namespace Unity.AI.Runtime; public sealed record AIPromptTemplateSnapshot( string PromptVersion, string SystemPrompt, - string UserPromptTemplate, - string? MetadataJson); + string UserPrompt, + string? MetadataJson) +{ + public UnityPromptAssetManifest? Manifest { get; } = ParseManifest(MetadataJson); + + private static UnityPromptAssetManifest? ParseManifest(string? metadataJson) + => string.IsNullOrWhiteSpace(metadataJson) + ? null + : TryDeserialize(metadataJson); + + private static UnityPromptAssetManifest? TryDeserialize(string metadataJson) + { + try + { + return JsonSerializer.Deserialize(metadataJson); + } + catch + { + return null; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateStore.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateStore.cs new file mode 100644 index 0000000000..bd7d0c5037 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIPromptTemplateStore.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.AI.Runtime; + +public class AIPromptTemplateStore( + IRepository promptRepository, + IDataFilter multiTenantDataFilter) : IAIPromptTemplateStore, ITransientDependency +{ + public async Task GetRequiredPromptAsync( + string promptType, + string promptVersion, + CancellationToken cancellationToken = default) + { + var normalizedPromptVersion = OpenAIPromptRenderer.ResolvePromptVersion(promptVersion); + var versionNumber = OpenAIPromptRenderer.ResolvePromptVersionNumber(normalizedPromptVersion); + + using (multiTenantDataFilter.Disable()) + { + var prompt = await promptRepository.FindAsync(p => + p.TenantId == null && p.Name == promptType && p.VersionNumber == versionNumber, + cancellationToken: cancellationToken); + if (prompt == null || !prompt.IsActive) + { + throw new InvalidOperationException( + $"AI prompt '{promptType}' version '{normalizedPromptVersion}' is not configured."); + } + + return new AIPromptTemplateSnapshot( + normalizedPromptVersion, + prompt.SystemPrompt, + prompt.UserPrompt, + prompt.MetadataJson); + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index b49d718e15..e481ee5823 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs @@ -14,6 +14,39 @@ public static AIResponseValidationResult ValidateAttachmentSummaryText(string re : AIResponseValidationResult.Invalid("Attachment summary response was empty."); } + public static AIResponseValidationResult ValidateAttachmentSummaryBatchJson(string response) + { + if (!TryParseRootObject(response, out var root)) + { + return AIResponseValidationResult.Invalid("Attachment summary batch response was not valid JSON."); + } + + if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) + { + return AIResponseValidationResult.Invalid("Attachment summary batch response is missing required field 'attachments' (expected array)."); + } + + foreach (var attachment in attachments.EnumerateArray()) + { + if (attachment.ValueKind != JsonValueKind.Object) + { + return AIResponseValidationResult.Invalid("Attachment summary batch response includes an invalid attachment item."); + } + + if (!attachment.TryGetProperty("attachmentId", out var attachmentId) || attachmentId.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(attachmentId.GetString())) + { + return AIResponseValidationResult.Invalid("Attachment summary batch response is missing a valid attachmentId."); + } + + if (!attachment.TryGetProperty(AIJsonKeys.Summary, out var summary) || summary.ValueKind != JsonValueKind.String) + { + return AIResponseValidationResult.Invalid("Attachment summary batch response is missing a valid summary."); + } + } + + return AIResponseValidationResult.Success(); + } + public static AIResponseValidationResult ValidateApplicationAnalysisJson(string response) { if (!TryParseRootObject(response, out var root)) @@ -26,6 +59,13 @@ public static AIResponseValidationResult ValidateApplicationAnalysisJson(string return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Decision}' (expected string)."); } + var normalizedDecision = (decision.GetString() ?? string.Empty).Trim().ToUpperInvariant(); + if (normalizedDecision != "PROCEED" && normalizedDecision != "HOLD") + { + return AIResponseValidationResult.Invalid( + $"Application analysis response has invalid '{AIJsonKeys.Decision}' value. Expected 'PROCEED' or 'HOLD'."); + } + if (!root.TryGetProperty(AIJsonKeys.Errors, out var errors) || errors.ValueKind != JsonValueKind.Array) { return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Errors}' (expected array)."); @@ -46,6 +86,18 @@ public static AIResponseValidationResult ValidateApplicationAnalysisJson(string return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Recommendations}' (expected array)."); } + if (summaries.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid( + $"Application analysis response must include at least one item in '{AIJsonKeys.Summaries}'."); + } + + if (recommendations.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid( + $"Application analysis response must include at least one item in '{AIJsonKeys.Recommendations}'."); + } + return AIResponseValidationResult.Success(); } @@ -81,9 +133,9 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) || confidenceValue.ValueKind != JsonValueKind.Number - || !confidenceValue.TryGetInt32(out var confidence) - || confidence < 0 - || confidence > 100) + || !confidenceValue.TryGetDecimal(out var confidence) + || confidence < 0m + || confidence > 1m) { return AIResponseValidationResult.Invalid( $"Application scoring response is missing a valid confidence score for question id '{questionId}'."); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/IAIPromptTemplateStore.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/IAIPromptTemplateStore.cs new file mode 100644 index 0000000000..96d583b756 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/IAIPromptTemplateStore.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Unity.AI.Runtime; + +public interface IAIPromptTemplateStore +{ + Task GetRequiredPromptAsync( + string promptType, + string promptVersion, + CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs index d608869f30..7490142fbe 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIConfigurationResolver.cs @@ -1,168 +1,259 @@ using Microsoft.Extensions.Configuration; using System; -using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.AI.Operations; +using Volo.Abp.Data; using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; namespace Unity.AI.Runtime; -public class OpenAIConfigurationResolver(IConfiguration configuration) : ITransientDependency +public class OpenAIConfigurationResolver( + IRepository modelRepository, + IRepository operationRepository, + IRepository promptRepository, + IConfiguration configuration, + IDataFilter multiTenantDataFilter) : ITransientDependency { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly IRepository _modelRepository = modelRepository; + private readonly IRepository _operationRepository = operationRepository; + private readonly IRepository _promptRepository = promptRepository; private readonly IConfiguration _configuration = configuration; + private readonly IDataFilter _multiTenantDataFilter = multiTenantDataFilter; + + public string ResolveProviderName() => Required("Azure:Operations:Defaults:Provider"); - public string ResolveProviderName(string? operationName = null) + public Task ResolveApiKeyAsync(string? modelName = null, CancellationToken cancellationToken = default) { - if (!string.IsNullOrWhiteSpace(operationName)) + var providerName = Required("Azure:Operations:Defaults:Provider"); + return Task.FromResult(Required($"Azure:{providerName}:ApiKey")); + } + + public async Task ResolveOperationSettingsAsync( + string operationName, + CancellationToken cancellationToken = default) + { + var operation = await ResolveOperationAsync(operationName, cancellationToken); + if (operation == null) { - var operationProvider = Optional($"Azure:Operations:{operationName}:Provider"); - if (operationProvider != null) - { - return operationProvider; - } + throw new InvalidOperationException($"AI operation '{operationName}' is not configured."); } - return Required("Azure:Operations:Defaults:Provider"); - } + var model = await _modelRepository.GetAsync(operation.AIModelId, cancellationToken: cancellationToken); + if (!model.IsActive) + { + throw new InvalidOperationException($"AI model '{model.Name}' is inactive."); + } - public string ResolveApiKey(string? operationName = null) - { - var providerName = ResolveProviderName(operationName); - return Required($"Azure:{providerName}:ApiKey"); - } + var modelSettings = ResolveModelSettings(model); + var providerName = Required("Azure:Operations:Defaults:Provider"); + var endpoint = Required($"Azure:{providerName}:Endpoint"); + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out _)) + { + throw new InvalidOperationException($"Azure:{providerName}:Endpoint must be a valid absolute URI."); + } + var prompt = await LoadPromptAsync(operation.AIPromptId, cancellationToken); + if (!prompt.IsActive) + { + throw new InvalidOperationException($"AI prompt '{prompt.Name}' v{prompt.VersionNumber} is not active."); + } - public OpenAIOperationSettings ResolveOperationSettings(string operationName) - { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var apiKey = Required($"Azure:{providerName}:ApiKey"); - var endpoint = ResolveEndpointUri(providerName); - var deploymentName = RequiredProfile(providerName, profileName, "DeploymentName"); - var promptVersion = Optional($"Azure:Operations:{operationName}:PromptVersion") - ?? Required("Azure:Operations:Defaults:PromptVersion"); + if (operation.CompletionTokens <= 0) + { + throw new InvalidOperationException($"AI operation '{operation.Name}' must define a positive CompletionTokens value."); + } + var apiKey = Required($"Azure:{providerName}:ApiKey"); return new OpenAIOperationSettings( providerName, - profileName, + model.Name, apiKey, - endpoint, - deploymentName, - ResolveMaxOutputTokenCountSupported(operationName), - ResolveConfiguredTemperature(operationName), - ResolveCompletionTokens(operationName), - promptVersion); + new Uri(endpoint), + Required($"Azure:{providerName}:Profiles:{model.Name}:DeploymentName"), + modelSettings.MaxOutputTokenCountSupported, + modelSettings.Temperature, + operation.CompletionTokens, + $"v{prompt.VersionNumber}"); } - public double? ResolveConfiguredTemperature(string? operationName = null) + public async Task ResolveConfiguredTemperatureAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var profileTemperature = OptionalProfile(providerName, profileName, "Temperature"); - if (profileTemperature != null - && double.TryParse(profileTemperature, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedTemperature)) + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) { - return parsedTemperature; + return modelConfiguration.Value.Settings.Temperature; } - return null; + throw new InvalidOperationException("AI model is not configured."); } - public bool ResolveMaxOutputTokenCountSupported(string? operationName = null) + public async Task ResolveMaxOutputTokenCountSupportedAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - var key = ProfileKey(providerName, profileName, "MaxOutputTokenCountSupported"); - var configuredValue = Optional(key); - if (configuredValue == null) + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) { - return true; - } + var providerName = Required("Azure:Operations:Defaults:Provider"); + var profileName = modelConfiguration.Value.Model.Name; + var configuredValue = Optional($"Azure:{providerName}:Profiles:{profileName}:MaxOutputTokenCountSupported"); + if (configuredValue != null) + { + if (bool.TryParse(configuredValue, out var parsedValue)) + { + return parsedValue; + } - if (bool.TryParse(configuredValue, out var parsedValue)) - { - return parsedValue; + throw new InvalidOperationException($"Azure:{providerName}:Profiles:{profileName}:MaxOutputTokenCountSupported is not a valid boolean."); + } + + return modelConfiguration.Value.Settings.MaxOutputTokenCountSupported; } - throw new InvalidOperationException($"{key} must be 'true' or 'false'."); + throw new InvalidOperationException("AI model is not configured."); } - public int ResolveCompletionTokens(string operationName) + public async Task ResolveCompletionTokensAsync(string operationName, CancellationToken cancellationToken = default) { - var configuredValue = OptionalPositiveInt($"Azure:Operations:{operationName}:MaxCompletionTokens"); - if (configuredValue is > 0) + var operation = await ResolveOperationAsync(operationName, cancellationToken); + if (operation == null) { - return configuredValue.Value; + throw new InvalidOperationException($"AI operation '{operationName}' is not configured."); } - var defaultConfiguredValue = OptionalPositiveInt("Azure:Operations:Defaults:MaxCompletionTokens"); - if (defaultConfiguredValue is > 0) + var model = await _modelRepository.GetAsync(operation.AIModelId, cancellationToken: cancellationToken); + if (!model.IsActive) { - return defaultConfiguredValue.Value; + throw new InvalidOperationException($"AI model '{model.Name}' is inactive."); } - throw new InvalidOperationException($"AI max completion tokens are not configured for operation '{operationName}'."); + if (operation.CompletionTokens <= 0) + { + throw new InvalidOperationException($"AI operation '{operation.Name}' must define a positive CompletionTokens value."); + } + + return operation.CompletionTokens; } - public string ResolvePromptVersion(string operationName) + public Task ResolvePromptVersionAsync(string operationName, CancellationToken cancellationToken = default) { - return Optional($"Azure:Operations:{operationName}:PromptVersion") - ?? Required("Azure:Operations:Defaults:PromptVersion"); + return ResolvePromptVersionAsyncCore(operationName, cancellationToken); } - public Uri ResolveEndpoint(string? operationName = null) + public async Task ResolveEndpointAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - return ResolveEndpointUri(providerName); + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) + { + var providerName = Required("Azure:Operations:Defaults:Provider"); + return new Uri(Required($"Azure:{providerName}:Endpoint")); + } + + throw new InvalidOperationException("AI model is not configured."); } - public string ResolveDeploymentName(string? operationName = null) + public async Task ResolveDeploymentNameAsync(string? modelName = null, CancellationToken cancellationToken = default) { - var providerName = ResolveProviderName(operationName); - var profileName = ResolveProfileName(operationName); - return RequiredProfile(providerName, profileName, "DeploymentName"); + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) + { + var providerName = Required("Azure:Operations:Defaults:Provider"); + return Required($"Azure:{providerName}:Profiles:{modelConfiguration.Value.Model.Name}:DeploymentName"); + } + + throw new InvalidOperationException("AI model is not configured."); } - private string ResolveProfileName(string? operationName) + public async Task ResolveProfileNameAsync(string? modelName = null, CancellationToken cancellationToken = default) { - if (!string.IsNullOrWhiteSpace(operationName)) + var modelConfiguration = await ResolveModelConfigurationAsync(modelName, cancellationToken); + if (modelConfiguration != null) { - var operationProfile = Optional($"Azure:Operations:{operationName}:Profile"); - if (operationProfile != null) - { - return operationProfile; - } + return modelConfiguration.Value.Model.Name; } - return Required("Azure:Operations:Defaults:Profile"); + throw new InvalidOperationException("AI model is not configured."); } - private string RequiredProfile(string providerName, string profileName, string settingName) + private async Task<(AIModel Model, AIModelSettings Settings)?> ResolveModelConfigurationAsync( + string? modelName, + CancellationToken cancellationToken) { - var key = ProfileKey(providerName, profileName, settingName); - return Required(key); + var model = await ResolveModelAsync(modelName, cancellationToken); + if (model == null) + { + return null; + } + + var settings = JsonSerializer.Deserialize(model.SettingsJson, JsonOptions); + if (settings == null) + { + throw new InvalidOperationException($"AI model '{model.Name}' has invalid settings JSON."); + } + + return (model, settings); } - private string? OptionalProfile(string providerName, string profileName, string settingName) + private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) { - return Optional(ProfileKey(providerName, profileName, settingName)); + var operations = await _operationRepository.GetListAsync( + operation => operation.IsActive, + cancellationToken: cancellationToken); + + return operations.FirstOrDefault(operation => + string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); } - private string Required(string key) + private static AIModelSettings ResolveModelSettings(AIModel model) { - return Optional(key) ?? throw new InvalidOperationException($"{key} is not configured."); + var settings = JsonSerializer.Deserialize(model.SettingsJson, JsonOptions); + if (settings == null) + { + throw new InvalidOperationException($"AI model '{model.Name}' has invalid settings JSON."); + } + + return settings; } - private Uri ResolveEndpointUri(string providerName) + private async Task ResolveModelAsync(string? modelName, CancellationToken cancellationToken) { - var key = $"Azure:{providerName}:Endpoint"; - var endpoint = Required(key); + var activeModels = await _modelRepository.GetListAsync(model => model.IsActive, cancellationToken: cancellationToken); + if (activeModels.Count == 0) + { + return null; + } - try + if (!string.IsNullOrWhiteSpace(modelName)) { - return new Uri(endpoint); + return activeModels.FirstOrDefault(model => + string.Equals(model.Name, modelName, StringComparison.OrdinalIgnoreCase)); } - catch (UriFormatException ex) + + var configuredDefaultProfile = Optional("Azure:Operations:Defaults:Profile"); + if (!string.IsNullOrWhiteSpace(configuredDefaultProfile)) { - throw new InvalidOperationException($"{key} must be a valid absolute URI.", ex); + var configuredDefaultModel = activeModels.FirstOrDefault(model => + string.Equals(model.Name, configuredDefaultProfile, StringComparison.OrdinalIgnoreCase)); + if (configuredDefaultModel != null) + { + return configuredDefaultModel; + } } + + return null; + } + + private string Required(string key) + { + return Optional(key) ?? throw new InvalidOperationException($"{key} is not configured."); } private string? Optional(string key) @@ -171,14 +262,28 @@ private Uri ResolveEndpointUri(string providerName) return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } - private int? OptionalPositiveInt(string key) + private async Task ResolvePromptVersionAsyncCore(string operationName, CancellationToken cancellationToken) { - var value = _configuration.GetValue(key); - return value is > 0 ? value : null; + var operation = await ResolveOperationAsync(operationName, cancellationToken); + if (operation == null) + { + throw new InvalidOperationException($"AI operation '{operationName}' is not configured."); + } + + var prompt = await LoadPromptAsync(operation.AIPromptId, cancellationToken); + if (!prompt.IsActive) + { + throw new InvalidOperationException($"AI prompt '{prompt.Name}' v{prompt.VersionNumber} is not active."); + } + + return $"v{prompt.VersionNumber}"; } - private static string ProfileKey(string providerName, string profileName, string settingName) + private async Task LoadPromptAsync(Guid promptId, CancellationToken cancellationToken) { - return $"Azure:{providerName}:Profiles:{profileName}:{settingName}"; + using (_multiTenantDataFilter.Disable()) + { + return await _promptRepository.GetAsync(promptId, cancellationToken: cancellationToken); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs index 64511fd9c9..9080271150 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIPromptRenderer.cs @@ -7,15 +7,6 @@ namespace Unity.AI.Runtime; public class OpenAIPromptRenderer : ITransientDependency { - private const string PromptVersionV0 = "v0"; - private const string PromptVersionV1 = "v1"; - private static readonly Dictionary PromptProfiles = - new(StringComparer.Ordinal) - { - [PromptVersionV0] = PromptVersionV0, - [PromptVersionV1] = PromptVersionV1 - }; - public static string BuildApplicationScoringResponseTemplate(string sectionPayloadJson) { try @@ -131,9 +122,12 @@ public static string ResolvePromptVersion(string? version) throw new InvalidOperationException("AI prompt version is not configured."); } - if (PromptProfiles.TryGetValue(version.Trim(), out var selectedVersion)) + var normalizedVersion = version.Trim(); + if (normalizedVersion.Length >= 2 && + normalizedVersion[0] == 'v' && + int.TryParse(normalizedVersion.AsSpan(1), out _)) { - return selectedVersion; + return normalizedVersion; } throw new InvalidOperationException($"AI prompt version '{version}' is not supported."); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs index 1abacf8dda..05c1ebb027 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIResponseParser.cs @@ -131,7 +131,7 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string : string.Empty; var confidence = property.Value.TryGetProperty("confidence", out var confidenceProp) && confidenceProp.ValueKind == JsonValueKind.Number && - confidenceProp.TryGetInt32(out var parsedConfidence) + confidenceProp.TryGetDecimal(out var parsedConfidence) ? NormalizeConfidence(parsedConfidence) : 0; @@ -151,6 +151,49 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string return response; } + public static AttachmentSummaryBatchResponse ParseAttachmentSummaryBatchResponse(string raw) + { + var response = new AttachmentSummaryBatchResponse(); + if (!TryParseJsonObjectFromResponse(raw, out var root)) + { + return response; + } + + if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) + { + return response; + } + + foreach (var attachment in attachments.EnumerateArray()) + { + if (attachment.ValueKind != JsonValueKind.Object) + { + continue; + } + + var attachmentId = attachment.TryGetProperty("attachmentId", out var idProp) && idProp.ValueKind == JsonValueKind.String + ? idProp.GetString() ?? string.Empty + : string.Empty; + + if (string.IsNullOrWhiteSpace(attachmentId)) + { + continue; + } + + var summary = attachment.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String + ? summaryProp.GetString() ?? string.Empty + : string.Empty; + + response.Attachments.Add(new AttachmentSummaryBatchItemResponse + { + AttachmentId = attachmentId, + Summary = summary + }); + } + + return response; + } + private static IEnumerable ParseFindings(JsonElement findingsArray) { foreach (var item in findingsArray.EnumerateArray()) @@ -242,10 +285,11 @@ private static bool TryGetArrayProperty(JsonElement element, string propertyName return true; } - private static int NormalizeConfidence(int confidence) + private static int NormalizeConfidence(decimal confidence) { - var clamped = Math.Clamp(confidence, 0, 100); - var rounded = (int)Math.Round(clamped / 5.0, MidpointRounding.AwayFromZero) * 5; + var clamped = Math.Clamp(confidence, 0m, 1m); + var percentage = clamped * 100m; + var rounded = (int)Math.Round(percentage / 10m, MidpointRounding.AwayFromZero) * 10; return Math.Clamp(rounded, 0, 100); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs index d7f6f35e64..3cafa504d9 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAIRuntimeService.cs @@ -9,6 +9,7 @@ using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Responses; +using Volo.Abp; using Volo.Abp.DependencyInjection; namespace Unity.AI.Runtime @@ -42,16 +43,7 @@ public OpenAIRuntimeService( public Task IsAvailableAsync() { - try - { - _openAIConfigurationResolver.ResolveApiKey(); - return Task.FromResult(true); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "AI is unavailable because the OpenAI configuration could not be resolved."); - return Task.FromResult(false); - } + return IsAvailableCoreAsync(); } public async Task GenerateApplicationAnalysisAsync(ApplicationAnalysisRequest request, CancellationToken cancellationToken = default) @@ -59,7 +51,7 @@ public async Task GenerateApplicationAnalysisAsync( try { ArgumentNullException.ThrowIfNull(request); - var settings = _openAIConfigurationResolver.ResolveOperationSettings(ApplicationAnalysisPromptType); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationAnalysisPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( ApplicationAnalysisPromptType, request.PromptVersion ?? settings.PromptVersion, @@ -79,7 +71,7 @@ public async Task GenerateApplicationAnalysisAsync( var attachments = JsonSerializer.Serialize(attachmentsPayload, AIJsonDefaults.Indented); var systemPrompt = promptTemplate.SystemPrompt; var applicationAnalysisContent = AIPromptTemplateRenderer.BuildApplicationAnalysisUserPrompt( - promptTemplate.UserPromptTemplate, + promptTemplate.UserPrompt, schema, data, attachments, @@ -101,7 +93,25 @@ public async Task GenerateApplicationAnalysisAsync( if (result.Outcome != AIOperationOutcome.Success) { - return new ApplicationAnalysisResponse(); + var providerDetails = result.Response?.RawResponse; + if (string.IsNullOrWhiteSpace(providerDetails)) + { + providerDetails = result.Response?.Content; + } + + if (!string.IsNullOrWhiteSpace(providerDetails) && providerDetails.Length > 400) + { + providerDetails = providerDetails[..400]; + } + + _logger.LogError( + "Application analysis generation failed with outcome {Outcome} and failure category {FailureCategory}. HTTP status {HttpStatusCode}. Provider details: {ProviderDetails}", + result.Outcome, + result.FailureCategory, + result.Response?.HttpStatusCode?.ToString() ?? "n/a", + providerDetails ?? "n/a"); + + throw new UserFriendlyException("Application analysis generation failed."); } return OpenAIResponseParser.ParseApplicationAnalysisResponse(result.Content); @@ -112,8 +122,8 @@ public async Task GenerateApplicationAnalysisAsync( } catch (Exception ex) { - _logger.LogError(ex, "Error generating application analysis."); - return new ApplicationAnalysisResponse(); + _logger.LogError(ex, "Application analysis generation failed."); + throw; } } @@ -125,7 +135,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta try { - var settings = _openAIConfigurationResolver.ResolveOperationSettings(AttachmentSummaryPromptType); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(AttachmentSummaryPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( AttachmentSummaryPromptType, request.PromptVersion ?? settings.PromptVersion, @@ -135,25 +145,19 @@ public async Task GenerateAttachmentSummaryAsync(Atta var prompt = promptTemplate.SystemPrompt; var attachmentText = string.IsNullOrWhiteSpace(extractedText) ? null : extractedText; - if (attachmentText != null) - { - _logger.LogDebug("Received {TextLength} extracted characters for {FileName}", attachmentText.Length, fileName); - } - else - { - _logger.LogDebug("No text extracted from {FileName}, analyzing metadata only", fileName); - } - - var attachmentPayload = new + var attachmentPayload = new[] { - name = fileName, - contentType, - text = attachmentText + new + { + name = fileName, + contentType, + text = attachmentText + } }; - var attachment = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); - var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryUserPrompt( - promptTemplate.UserPromptTemplate, - attachment, + var attachments = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); + var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( + promptTemplate.UserPrompt, + attachments, promptTemplate.MetadataJson); await _promptFileLogger.LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, prompt, contentToAnalyze, cancellationToken); @@ -188,7 +192,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta } catch (Exception ex) { - _logger.LogError(ex, "Error generating attachment summary for {FileName}", fileName); + _logger.LogError(ex, "Attachment summary generation failed for {FileName}.", fileName); return new AttachmentSummaryResponse { Summary = $"AI analysis not available for this attachment ({fileName})." @@ -196,12 +200,74 @@ public async Task GenerateAttachmentSummaryAsync(Atta } } + public async Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + if (request.Attachments is null || request.Attachments.Count == 0) + { + return new AttachmentSummaryBatchResponse(); + } + + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(AttachmentSummaryPromptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + AttachmentSummaryPromptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + + var attachmentsPayload = request.Attachments.Select(attachment => new + { + attachmentId = attachment.AttachmentId, + name = string.IsNullOrWhiteSpace(attachment.FileName) ? "attachment" : attachment.FileName.Trim(), + contentType = attachment.ContentType ?? "application/octet-stream", + text = string.IsNullOrWhiteSpace(attachment.ExtractedText) ? null : attachment.ExtractedText + }); + + var attachments = JsonSerializer.Serialize(attachmentsPayload, AIJsonDefaults.Indented); + var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( + promptTemplate.UserPrompt, + attachments, + promptTemplate.MetadataJson); + + await _promptFileLogger.LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, promptTemplate.SystemPrompt, contentToAnalyze, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + contentToAnalyze, + promptTemplate.SystemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateAttachmentSummaryBatchJson, + "attachment summary batch", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput, cancellationToken); + + if (result.Outcome != AIOperationOutcome.Success) + { + return new AttachmentSummaryBatchResponse(); + } + + return OpenAIResponseParser.ParseAttachmentSummaryBatchResponse(result.Content); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Attachment summary batch generation failed."); + return new AttachmentSummaryBatchResponse(); + } + } + public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { - var settings = _openAIConfigurationResolver.ResolveOperationSettings(ApplicationScoringPromptType); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationScoringPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( ApplicationScoringPromptType, request.PromptVersion ?? settings.PromptVersion, @@ -228,7 +294,7 @@ public async Task GenerateApplicationScoringAsync(Ap } var applicationScoringContent = AIPromptTemplateRenderer.BuildApplicationScoringUserPrompt( - promptTemplate.UserPromptTemplate, + promptTemplate.UserPrompt, dataJson, attachments, section, @@ -262,7 +328,7 @@ public async Task GenerateApplicationScoringAsync(Ap } catch (Exception ex) { - _logger.LogError(ex, "Error generating application scoring answers for section {SectionName}", request.SectionName); + _logger.LogError(ex, "Application scoring generation failed for section {SectionName}.", request.SectionName); return new ApplicationScoringResponse(); } } @@ -337,6 +403,20 @@ private async Task GenerateWithRetryAsync( return lastResult; } + private async Task IsAvailableCoreAsync() + { + try + { + await _openAIConfigurationResolver.ResolveApiKeyAsync(); + return true; + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "AI is unavailable because the OpenAI configuration could not be resolved."); + return false; + } + } + private static string ResolveNarrativeContent(AIOperationResult result) { return result.Outcome switch diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs index db1cf6fdd2..769a538af1 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/OpenAITransportService.cs @@ -37,23 +37,16 @@ public async Task GenerateSummaryAsync( ? "You are a professional grant analyst for the BC Government." : systemPrompt; - var options = new ChatCompletionOptions(); - if (settings.MaxOutputTokenCountSupported) + var messages = new List { - options.MaxOutputTokenCount = maxTokens; - } - - if (settings.Temperature.HasValue) - { - options.Temperature = (float)settings.Temperature.Value; - } - - var result = await _chatClientFactory.Create(settings).CompleteChatAsync( - [ - new SystemChatMessage(resolvedSystemPrompt), - new UserChatMessage(content ?? string.Empty) - ], - options, + new SystemChatMessage(resolvedSystemPrompt), + new UserChatMessage(content ?? string.Empty) + }; + + var result = await CompleteChatWithTemperatureFallbackAsync( + settings, + messages, + maxTokens, cancellationToken); var completion = result.Value; @@ -110,6 +103,70 @@ public async Task GenerateSummaryAsync( } } + private async Task> CompleteChatWithTemperatureFallbackAsync( + OpenAIOperationSettings settings, + IReadOnlyList messages, + int maxTokens, + CancellationToken cancellationToken) + { + try + { + return await _chatClientFactory.Create(settings).CompleteChatAsync( + messages, + BuildOptions(settings, maxTokens, includeTemperature: true), + cancellationToken); + } + catch (ClientResultException ex) + { + var responseContent = ex.GetRawResponse()?.Content?.ToString() ?? ex.Message; + if (!ShouldRetryWithoutTemperature(settings, ex.Status, responseContent)) + { + throw; + } + + _logger.LogWarning( + ex, + "Retrying OpenAI request without temperature after provider rejected the temperature parameter for profile {ProfileName}.", + settings.ProfileName); + + return await _chatClientFactory.Create(settings).CompleteChatAsync( + messages, + BuildOptions(settings, maxTokens, includeTemperature: false), + cancellationToken); + } + } + + private static ChatCompletionOptions BuildOptions(OpenAIOperationSettings settings, int maxTokens, bool includeTemperature) + { + var options = new ChatCompletionOptions(); + if (settings.MaxOutputTokenCountSupported) + { + options.MaxOutputTokenCount = maxTokens; + } + + if (includeTemperature && settings.Temperature.HasValue) + { + options.Temperature = (float)settings.Temperature.Value; + } + + return options; + } + + private static bool ShouldRetryWithoutTemperature(OpenAIOperationSettings settings, int statusCode, string responseContent) + { + if (!settings.Temperature.HasValue || statusCode != 400 || string.IsNullOrWhiteSpace(responseContent)) + { + return false; + } + + var lowered = responseContent.ToLowerInvariant(); + return lowered.Contains("temperature") + && (lowered.Contains("unsupported") + || lowered.Contains("not supported") + || lowered.Contains("not allowed") + || lowered.Contains("invalid")); + } + private static AIOperationResult MapFailureOutcome(HttpStatusCode statusCode, AIProviderResult response) { var statusCodeValue = (int)statusCode; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs index 092ea879d1..b799fbd3ea 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationMapperlyProfile.cs @@ -21,55 +21,22 @@ public partial class CreateUpdateAIPromptDtoToAIPromptMapper : MapperBase (AIPrompt)RuntimeHelpers.GetUninitializedObject(typeof(AIPrompt)); - [MapperIgnoreTarget(nameof(AIPrompt.Versions))] [MapperIgnoreTarget(nameof(AIPrompt.TenantId))] [MapperIgnoreTarget(nameof(AIPrompt.ConcurrencyStamp))] [MapperIgnoreTarget(nameof(AIPrompt.CreationTime))] [MapperIgnoreTarget(nameof(AIPrompt.CreatorId))] [MapperIgnoreTarget(nameof(AIPrompt.LastModificationTime))] [MapperIgnoreTarget(nameof(AIPrompt.LastModifierId))] + [MapperIgnoreTarget(nameof(AIPrompt.Name))] public override partial AIPrompt Map(CreateUpdateAIPromptDto source); - [MapperIgnoreTarget(nameof(AIPrompt.Versions))] [MapperIgnoreTarget(nameof(AIPrompt.TenantId))] [MapperIgnoreTarget(nameof(AIPrompt.ConcurrencyStamp))] [MapperIgnoreTarget(nameof(AIPrompt.CreationTime))] [MapperIgnoreTarget(nameof(AIPrompt.CreatorId))] [MapperIgnoreTarget(nameof(AIPrompt.LastModificationTime))] [MapperIgnoreTarget(nameof(AIPrompt.LastModifierId))] + [MapperIgnoreTarget(nameof(AIPrompt.Name))] public override partial void Map(CreateUpdateAIPromptDto source, AIPrompt destination); } -[Mapper] -public partial class AIPromptVersionToAIPromptVersionDtoMapper : MapperBase -{ - public override partial AIPromptVersionDto Map(AIPromptVersion source); - - public override partial void Map(AIPromptVersion source, AIPromptVersionDto destination); -} - -[Mapper] -public partial class CreateUpdateAIPromptVersionDtoToAIPromptVersionMapper : MapperBase -{ - [ObjectFactory] - private static AIPromptVersion CreateAIPromptVersion() => - (AIPromptVersion)RuntimeHelpers.GetUninitializedObject(typeof(AIPromptVersion)); - - [MapperIgnoreTarget(nameof(AIPromptVersion.Prompt))] - [MapperIgnoreTarget(nameof(AIPromptVersion.TenantId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.ConcurrencyStamp))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreatorId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModificationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModifierId))] - public override partial AIPromptVersion Map(CreateUpdateAIPromptVersionDto source); - - [MapperIgnoreTarget(nameof(AIPromptVersion.Prompt))] - [MapperIgnoreTarget(nameof(AIPromptVersion.TenantId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.ConcurrencyStamp))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.CreatorId))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModificationTime))] - [MapperIgnoreTarget(nameof(AIPromptVersion.LastModifierId))] - public override partial void Map(CreateUpdateAIPromptVersionDto source, AIPromptVersion destination); -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIDataSeeder.cs new file mode 100644 index 0000000000..07b0a29f08 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIDataSeeder.cs @@ -0,0 +1,18 @@ +using System.Threading.Tasks; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.DataSeed; + +public class AIDataSeeder( + AIPromptDataSeeder promptDataSeeder, + AIModelDataSeeder modelDataSeeder, + AIOperationDataSeeder operationDataSeeder) : IDataSeedContributor, ITransientDependency +{ + public async Task SeedAsync(DataSeedContext context) + { + await promptDataSeeder.SeedAsync(context); + await modelDataSeeder.SeedAsync(context); + await operationDataSeeder.SeedAsync(context); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIModelDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIModelDataSeeder.cs new file mode 100644 index 0000000000..7839db8e01 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIModelDataSeeder.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Unity.AI.DataSeed; + +public class AIModelDataSeeder( + IRepository modelRepository) : ITransientDependency +{ + private static readonly BuiltInModelDefinition[] BuiltInModels = + [ + new("Gpt4oMini", true, 0.3d), + new("Gpt5Mini", false, null), + new("Gpt5Nano", false, null) + ]; + + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId != null) + { + return; + } + + foreach (var model in BuiltInModels) + { + await EnsureModelAsync(model); + } + } + + private async Task EnsureModelAsync(BuiltInModelDefinition definition) + { + var settings = new AIModelSettings + { + MaxOutputTokenCountSupported = definition.MaxOutputTokenCountSupported, + Temperature = definition.Temperature + }; + + var existing = await modelRepository.FirstOrDefaultAsync(model => model.Name == definition.Name); + if (existing != null) + { + existing.IsActive = true; + existing.SettingsJson = JsonSerializer.Serialize(settings); + await modelRepository.UpdateAsync(existing, autoSave: true); + return; + } + + await modelRepository.InsertAsync( + new AIModel(Guid.CreateVersion7(), definition.Name) + { + IsActive = true, + SettingsJson = JsonSerializer.Serialize(settings) + }, + autoSave: true); + } + + private sealed record BuiltInModelDefinition( + string Name, + bool MaxOutputTokenCountSupported, + double? Temperature); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs new file mode 100644 index 0000000000..bf8357a391 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIOperationDataSeeder.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using System.Threading.Tasks; +using Unity.AI.Domain; +using Unity.AI.Operations; +using Unity.AI.Prompts; +using Unity.GrantManager.GrantApplications; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; + +namespace Unity.AI.DataSeed; + +public class AIOperationDataSeeder( + IRepository operationRepository, + IRepository modelRepository, + IRepository promptRepository, + ICurrentTenant currentTenant, + ILogger logger) : ITransientDependency +{ + private const string DefaultModelName = "Gpt5Mini"; + + private static readonly BuiltInOperationDefinition[] BuiltInOperations = + [ + new(AIPromptTypes.ApplicationAnalysis, AIPromptTypes.ApplicationAnalysis, 1, 4000), + new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), + new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000) + ]; + + public async Task SeedAsync(DataSeedContext context) + { + if (context.TenantId != null) + { + return; + } + + using (currentTenant.Change(null)) + { + var model = await EnsureModelAsync(DefaultModelName); + if (model == null) + { + logger.LogWarning("AI operation seeding skipped: model '{ModelName}' is missing.", DefaultModelName); + return; + } + + foreach (var definition in BuiltInOperations) + { + await EnsureOperationAsync(definition, model); + } + } + } + + private async Task EnsureOperationAsync(BuiltInOperationDefinition definition, AIModel model) + { + var prompt = await ResolvePromptAsync(definition.PromptName, definition.PromptVersionNumber); + if (prompt == null) + { + logger.LogWarning( + "AI operation seeding skipped: no active prompt found for operation '{OperationName}' and prompt '{PromptName}' version '{PromptVersionNumber}'.", + definition.OperationName, + definition.PromptName, + definition.PromptVersionNumber); + return; + } + + var existing = await operationRepository.FirstOrDefaultAsync(op => op.Name == definition.OperationName); + if (existing != null) + { + existing.AIModelId = model.Id; + existing.AIPromptId = prompt.Id; + existing.ExecutionMode = definition.ExecutionMode; + existing.CompletionTokens = definition.CompletionTokens; + existing.IsActive = true; + await operationRepository.UpdateAsync(existing, autoSave: true); + return; + } + + await operationRepository.InsertAsync( + new AIOperation(Guid.CreateVersion7(), definition.OperationName, model.Id, prompt.Id) + { + ExecutionMode = definition.ExecutionMode, + CompletionTokens = definition.CompletionTokens, + IsActive = true + }, + autoSave: true); + } + + private async Task EnsureModelAsync(string modelName) + { + var models = await modelRepository.GetListAsync(model => model.Name == modelName && model.IsActive); + return models.FirstOrDefault(); + } + + private async Task ResolvePromptAsync(string promptName, int promptVersionNumber) + { + return await promptRepository.FirstOrDefaultAsync(item => + item.Name == promptName && + item.VersionNumber == promptVersionNumber && + item.IsActive); + } + + private sealed record BuiltInOperationDefinition( + string OperationName, + string PromptName, + int PromptVersionNumber, + int CompletionTokens, + AIExecutionMode ExecutionMode = AIExecutionMode.Sequential); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs index 52c13be7e3..64041c1d0b 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/DataSeed/AIPromptDataSeeder.cs @@ -13,20 +13,12 @@ namespace Unity.AI.DataSeed; /// /// Seeds the built-in AI prompts (application analysis, attachment summary, application scoring) into the host database. -/// Each prompt is seeded with two versions — v0 (original single-file prompts) and v1 (modular -/// prompts with separate rubric, score, output, and rules sections stored in MetadataJson). -/// The seeder is idempotent: it inserts fixed records when missing and does not overwrite existing records. +/// Each prompt family is represented as versioned rows in AIPrompts. /// public class AIPromptDataSeeder( IRepository promptRepository, - IRepository versionRepository, - ICurrentTenant currentTenant) : IDataSeedContributor, ITransientDependency + ICurrentTenant currentTenant) : ITransientDependency { - // Fixed deterministic GUIDs — never change these; they ensure idempotent re-seeding - private static readonly Guid AnalysisPromptId = new("4a100001-1000-4000-a000-000000000001"); - private static readonly Guid AttachmentPromptId = new("4a100001-1000-4000-a000-000000000002"); - private static readonly Guid ScoresheetPromptId = new("4a100001-1000-4000-a000-000000000003"); - public async Task SeedAsync(DataSeedContext context) { if (context.TenantId != null) return; // host database only @@ -43,86 +35,79 @@ public async Task SeedAsync(DataSeedContext context) private async Task SeedAnalysisPromptAsync() { + await EnsurePromptAsync(AIPromptTypes.ApplicationAnalysis, 0, AnalysisSystemV0, AnalysisUserV0); await EnsurePromptAsync( - AnalysisPromptId, AIPromptTypes.ApplicationAnalysis, - "Grant application analysis and review"); - - await EnsureVersionAsync( - AnalysisPromptId, - 0, - AnalysisSystemV0, - AnalysisUserV0, - "v0 — initial single-file analysis prompt"); - - await EnsureVersionAsync( - AnalysisPromptId, 1, AnalysisSystemV1, AnalysisUserV1, - "v1 — modular prompt with separate rubric, score, output, and rules sections", BuildSections( rubric: AnalysisRubric, score: AnalysisScore, output: AnalysisOutput, rules: AnalysisRules, commonRules: CommonRules)); + await EnsurePromptAsync( + AIPromptTypes.ApplicationAnalysis, + 2, + AnalysisSystemV2, + AnalysisUserV2, + BuildSections( + rubric: AnalysisRubricV2, + score: AnalysisScoreV2, + output: AnalysisOutputV2, + rules: AnalysisRulesV2, + commonRules: CommonRules)); } // ─── ATTACHMENT ─────────────────────────────────────────────────────────── private async Task SeedAttachmentPromptAsync() { + await EnsurePromptAsync(AIPromptTypes.AttachmentSummary, 0, AttachmentSystemV0, AttachmentUserV0); await EnsurePromptAsync( - AttachmentPromptId, AIPromptTypes.AttachmentSummary, - "Attachment summarization for grant review"); - - await EnsureVersionAsync( - AttachmentPromptId, - 0, - AttachmentSystemV0, - AttachmentUserV0, - "v0 — initial single-file attachment prompt"); - - await EnsureVersionAsync( - AttachmentPromptId, 1, AttachmentSystemV1, AttachmentUserV1, - "v1 — modular prompt with separate output and rules sections", BuildSections( output: AttachmentOutput, rules: AttachmentRules, commonRules: CommonRules)); + await EnsurePromptAsync( + AIPromptTypes.AttachmentSummary, + 2, + AttachmentSystemV2, + AttachmentUserV2, + BuildSections( + output: AttachmentOutputV2, + rules: AttachmentRulesV2, + commonRules: CommonRules)); } // ─── SCORESHEET ─────────────────────────────────────────────────────────── private async Task SeedScoresheetPromptAsync() { + await EnsurePromptAsync(AIPromptTypes.ApplicationScoring, 0, ScoresheetSystemV0, ScoresheetUserV0); await EnsurePromptAsync( - ScoresheetPromptId, AIPromptTypes.ApplicationScoring, - "Scoresheet section answering assistant"); - - await EnsureVersionAsync( - ScoresheetPromptId, - 0, - ScoresheetSystemV0, - ScoresheetUserV0, - "v0 — initial single-file scoresheet prompt"); - - await EnsureVersionAsync( - ScoresheetPromptId, 1, ScoresheetSystemV1, ScoresheetUserV1, - "v1 — modular prompt with separate output and rules sections", BuildSections( output: ScoresheetOutput, rules: ScoresheetRules, commonRules: CommonRules)); + await EnsurePromptAsync( + AIPromptTypes.ApplicationScoring, + 2, + ScoresheetSystemV2, + ScoresheetUserV2, + BuildSections( + output: ScoresheetOutputV2, + rules: ScoresheetRulesV2, + commonRules: CommonRules)); } // ─── HELPERS ────────────────────────────────────────────────────────────── @@ -137,50 +122,37 @@ private static string BuildSections( if (output != null) dict["OUTPUT"] = output; if (rules != null) dict["RULES"] = rules; if (commonRules != null) dict["COMMON_RULES"] = commonRules; - return JsonSerializer.Serialize(new { sections = dict }); + return JsonSerializer.Serialize(dict); } - private async Task EnsurePromptAsync(Guid promptId, string promptName, string? description) - { - var prompt = await promptRepository.FirstOrDefaultAsync(p => p.Id == promptId); - if (prompt != null) - { - return; - } - - await promptRepository.InsertAsync(new AIPrompt(promptId, promptName, PromptType.Skill) - { - Description = description, - IsActive = true - }); - } - - private async Task EnsureVersionAsync( - Guid promptId, + private async Task EnsurePromptAsync( + string promptName, int versionNumber, string systemPrompt, - string userPromptTemplate, - string developerNotes, + string userPrompt, string? metadataJson = null) { - var version = await versionRepository.FirstOrDefaultAsync( - v => v.PromptId == promptId && v.VersionNumber == versionNumber); - if (version != null) + var prompt = await promptRepository.FirstOrDefaultAsync( + p => p.Name == promptName && p.VersionNumber == versionNumber); + if (prompt != null) { + prompt.SystemPrompt = systemPrompt; + prompt.UserPrompt = userPrompt; + prompt.MetadataJson = metadataJson ?? "{}"; + prompt.IsActive = true; + await promptRepository.UpdateAsync(prompt, autoSave: true); return; } - await versionRepository.InsertAsync(new AIPromptVersion( + await promptRepository.InsertAsync(new AIPrompt( Guid.CreateVersion7(), - promptId, + promptName, versionNumber, systemPrompt, - userPromptTemplate) + userPrompt) { - DeveloperNotes = developerNotes, - IsPublished = true, - IsDeprecated = false, - MetadataJson = metadataJson + MetadataJson = metadataJson ?? "{}", + IsActive = true }); } @@ -393,26 +365,128 @@ 4. Return only the strongest evidence-backed reviewer conclusions. - Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. - Treat missing or empty values as findings only when they weaken rubric evidence. - Prefer material findings; avoid nitpicking. - - Do not restate basic application facts as findings unless they support a specific reviewer conclusion about readiness, feasibility, budget credibility, eligibility, or confidence in proceeding. - Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. - If ATTACHMENTS evidence is used, cite the attachment by name in detail. - Each detail must cite concrete evidence from DATA or ATTACHMENTS. - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. - Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. - Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. - - Do not state that one amount exceeds, matches, or conflicts with another unless the comparison is directly supported by the provided values. - - Do not treat ordinary lack of detailed supporting explanation as a material gap unless the provided evidence creates real uncertainty about feasibility, eligibility, or budget credibility. - - Prefer neutral evidence descriptions over evaluative adjectives unless the evidence directly supports a strong conclusion. - - Do not describe capacity, feasibility, or justification as strong, detailed, or well-supported unless the evidence shows more than the existence of basic organizational, budget, or timeline information. - - Do not infer community support, established partnerships, or delivery capacity from a single partner reference, staff count, or basic organizational status alone. - - Do not describe a timeline as realistic or feasible based only on start and end dates unless additional evidence supports deliverability. - Use 3-6 words for title. - Summary titles should name the specific substantive reviewer conclusion, strength, or risk, not a generic evaluation label or abstract category. - Each detail must be 1-2 complete sentences. - Summaries and recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. - Avoid generic praise, checklist language, and repeated conclusions across lists. - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. - - If no findings exist, return empty arrays. + - Errors and warnings may be empty. + - Summaries and recommendations must each include at least one item. + - Decision must be PROCEED or HOLD. + - Use summaries for overall application quality/readiness synthesis. + - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. + - Recommendations 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 recommendations for specific follow-up or verification actions. + - Return an empty array only when no concrete next action would help the reviewer. + """; + + // ── v2/analysis.system.txt ─────────────────────────────────────────────── + private const string AnalysisSystemV2 = """ + You are a careful grant review assistant for human reviewers. + Review the application and attachments for the strongest evidence-backed reviewer conclusions. + Do not fill gaps, assume compliance, or treat relevance as proof. + """; + + // ── v2/analysis.user.txt ───────────────────────────────────────────────── + private const string AnalysisUserV2 = """ + SCHEMA + {{SCHEMA}} + + DATA + {{DATA}} + + ATTACHMENTS + {{ATTACHMENTS}} + + RUBRIC + {{RUBRIC}} + + SCORE + {{SCORE}} + + RESPONSE + {{RESPONSE}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v2/analysis.rubric.txt ─────────────────────────────────────────────── + private const string AnalysisRubricV2 = """ + ELIGIBILITY REQUIREMENTS: Project aligns with program objectives; Applicant is an eligible entity; Budget is reasonable and justified; Timeline is realistic. + COMPLETENESS CHECKS: Required information is present; Supporting materials are provided where applicable; Description is clear. + FINANCIAL REVIEW: Requested amount is within limits; Budget matches scope; Matching funds or contributions are identified. + RISK ASSESSMENT: Applicant capacity; Feasibility; Compliance considerations; Delivery risks. + QUALITY INDICATORS: Clear objectives; Defined beneficiaries; Appropriate approach; Long-term sustainability. + """; + + // ── v2/analysis.score.txt ──────────────────────────────────────────────── + private const string AnalysisScoreV2 = """ + HIGH: Application demonstrates strong evidence across most rubric areas with few or no issues. + MEDIUM: Application has some gaps or weaknesses that require reviewer attention. + LOW: Application has significant gaps or risks across key rubric areas. + """; + + // ── v2/analysis.output.txt ─────────────────────────────────────────────── + private const string AnalysisOutputV2 = """ + { + "decision": "", + "errors": [ + { + "title": "", + "detail": "" + } + ], + "warnings": [ + { + "title": "", + "detail": "" + } + ], + "summaries": [ + { + "title": "", + "detail": "" + } + ], + "recommendations": [ + { + "title": "", + "detail": "" + } + ] + } + """; + + // ── v2/analysis.rules.txt ──────────────────────────────────────────────── + private const string AnalysisRulesV2 = """ + - Use only provided input sections as evidence. + - Do not invent fields, documents, requirements, or facts. + - Prefer, in order: direct evidence from DATA, specific supporting evidence from ATTACHMENTS, then broader context only when necessary. + - Treat missing or empty values as findings only when they weaken rubric evidence. + - Prefer material findings; avoid nitpicking. + - Prefer direct evidence from DATA over derivative statements in ATTACHMENTS when both address the same point. + - If ATTACHMENTS evidence is used, cite the attachment by name in detail. + - Each detail must cite concrete evidence from DATA or ATTACHMENTS. + - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as DATA, ATTACHMENTS, ProjectSummary, CustomField1, or OrganizationType. + - Refer to evidence by its plain-language meaning, quoted text, or attachment name rather than internal key names. + - Only include warnings when the evidence shows a specific, concrete risk, inconsistency, or meaningful uncertainty; a stated risk label alone is not enough. + - 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 recommendations must be concrete, distinct, reviewer-relevant, and specific to this application's evidence. + - Avoid generic praise, checklist language, and repeated conclusions across lists. + - Do not use a summary merely to say that supporting documents were provided; summarize the specific substantive evidence they add, or omit the finding. + - Errors and warnings may be empty. + - Summaries and recommendations must each include at least one item. - Decision must be PROCEED or HOLD. - Use summaries for overall application quality/readiness synthesis. - Use recommendations for concrete reviewer-facing next actions based on the provided evidence. @@ -490,6 +564,48 @@ 3. Return a concise reviewer-facing summary. - Return exactly one object with only the key: summary. """; + // ── v2/attachment.system.txt ───────────────────────────────────────────── + private const string AttachmentSystemV2 = """ + You are a careful grant review assistant for human reviewers. + Summarize the attachment itself, not the overall project. + Return a concise reviewer-facing summary. + """; + + // ── v2/attachment.user.txt ─────────────────────────────────────────────── + private const string AttachmentUserV2 = """ + ATTACHMENT + {{ATTACHMENT}} + + RESPONSE + {{RESPONSE}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v2/attachment.output.txt ───────────────────────────────────────────── + private const string AttachmentOutputV2 = """ + { + "summary": "" + } + """; + + // ── v2/attachment.rules.txt ────────────────────────────────────────────── + private const string AttachmentRulesV2 = """ + - Use only ATTACHMENT as evidence. + - Summarize actual content when ATTACHMENT.text is present; otherwise provide a conservative file-level summary. + - Describe the attachment itself rather than summarizing the overall project. + - Begin with what the attachment contains or provides, not the file name or file type, unless that metadata is necessary to describe the evidence. + - Do not invent missing details. + - Do not calculate or restate totals, sums, or aggregates unless they are explicitly present in ATTACHMENT.text. + - Write reviewer-facing natural language. Do not refer to prompt section names, internal field keys, or schema labels such as ATTACHMENT or ATTACHMENT.text. + - Refer to evidence by its plain-language meaning, quoted text, or file name rather than internal key names. + - Write 1-2 complete sentences. + - Summary must be grounded in concrete ATTACHMENT evidence. + - Return exactly one object with only the key: summary. + """; + // ── v0/scoresheet.system.txt ───────────────────────────────────────────── private const string ScoresheetSystemV0 = """ You are an expert grant application reviewer for the BC Government. @@ -518,14 +634,14 @@ Respond only with valid JSON in the exact format requested. For each question, provide: 1. The answer based on the application evidence 2. A brief rationale (1-2 complete sentences) citing concrete supporting evidence - 3. A confidence score from 0-100 (integer) indicating certainty in the selected answer + 3. A confidence score as a decimal fraction from 0.0 to 1.0. OUTPUT { - "": { + "": { "answer": "", "rationale": "", - "confidence": + "confidence": } } @@ -537,10 +653,64 @@ 2. A brief rationale (1-2 complete sentences) citing concrete supporting evidenc - answer type must match the question type. - For select list questions, return only the option number as a string, never label text. - rationale must be 1-2 complete sentences grounded in evidence. - - confidence must be an integer from 0 to 100 in increments of 5. + - confidence must be a decimal fraction from 0.0 to 1.0. - Return valid plain JSON only in the exact OUTPUT shape. """; + // ── v2/scoresheet.system.txt ───────────────────────────────────────────── + private const string ScoresheetSystemV2 = """ + You are a careful grant review assistant for human reviewers. + Answer each question in SECTION using only the provided DATA and ATTACHMENTS. + Choose the most conservative valid answer supported by the evidence. + If evidence is incomplete or indirect, explain the uncertainty in the rationale. + """; + + // ── v2/scoresheet.user.txt ─────────────────────────────────────────────── + private const string ScoresheetUserV2 = """ + DATA + {{DATA}} + + ATTACHMENTS + {{ATTACHMENTS}} + + SECTION + {{SECTION}} + + RESPONSE + {{RESPONSE}} + + RULES + {{RULES}} + {{COMMON_RULES}} + """; + + // ── v2/scoresheet.output.txt ───────────────────────────────────────────── + private const string ScoresheetOutputV2 = """ + { + "": { + "answer": "", + "rationale": "", + "confidence": + } + } + """; + + // ── v2/scoresheet.rules.txt ────────────────────────────────────────────── + private const string ScoresheetRulesV2 = """ + - Use only DATA and ATTACHMENTS as evidence. + - Do not invent missing application details. + - Prefer direct evidence of the exact condition asked. + - If evidence is insufficient, partial, indirect, missing, or non-specific, choose the most conservative valid answer and explain the uncertainty. + - Return exactly one answer object per question ID in SECTION.questions. + - Do not omit any question IDs from SECTION.questions. + - Do not add keys that are not question IDs from SECTION.questions. + - Use the exact question IDs from RESPONSE and SECTION.questions without alteration. + - Use RESPONSE as the output contract and fill every placeholder value. + - Each answer object must include: "answer", "rationale", and "confidence". + - Confidence is mandatory for every question and must always be a numeric decimal between 0.0 and 1.0. + - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. + """; + // ── v1/scoresheet.system.txt ───────────────────────────────────────────── private const string ScoresheetSystemV1 = """ ROLE @@ -581,7 +751,7 @@ 4. Choose the most conservative valid answer supported by that evidence. "": { "answer": "", "rationale": "", - "confidence": + "confidence": } } """; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModel.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModel.cs new file mode 100644 index 0000000000..511ba071ec --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModel.cs @@ -0,0 +1,24 @@ +using System; +using Volo.Abp.Domain.Entities.Auditing; + +namespace Unity.AI.Domain; + +public class AIModel : AuditedAggregateRoot +{ + public string Name { get; set; } = default!; + + public bool IsActive { get; set; } = true; + + /// Free-form model settings stored as JSON for dynamic runtime options. + public string SettingsJson { get; set; } = "{}"; + + protected AIModel() + { + } + + public AIModel(Guid id, string name) + { + Id = id; + Name = name; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModelSettings.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModelSettings.cs new file mode 100644 index 0000000000..7a9c268eac --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIModelSettings.cs @@ -0,0 +1,8 @@ +namespace Unity.AI.Domain; + +public class AIModelSettings +{ + public bool MaxOutputTokenCountSupported { get; set; } = true; + + public double? Temperature { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs new file mode 100644 index 0000000000..c4aa1f229f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIOperation.cs @@ -0,0 +1,36 @@ +using System; +using Unity.AI.Operations; +using Volo.Abp.Domain.Entities.Auditing; + +namespace Unity.AI.Domain; + +public class AIOperation : AuditedAggregateRoot +{ + public string Name { get; set; } = default!; + + public Guid AIModelId { get; set; } + + public AIModel AIModel { get; set; } = default!; + + public Guid AIPromptId { get; set; } + + public AIPrompt? AIPrompt { get; set; } + + public AIExecutionMode ExecutionMode { get; set; } = AIExecutionMode.Sequential; + + public int CompletionTokens { get; set; } + + public bool IsActive { get; set; } = true; + + protected AIOperation() + { + } + + public AIOperation(Guid id, string name, Guid aiModelId, Guid aiPromptId) + { + Id = id; + Name = name; + AIModelId = aiModelId; + AIPromptId = aiPromptId; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs index 8aeb74d3d2..3ece069435 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPrompt.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using Volo.Abp.Domain.Entities.Auditing; using Volo.Abp.MultiTenancy; @@ -7,25 +6,36 @@ namespace Unity.AI.Domain; public class AIPrompt : AuditedAggregateRoot, IMultiTenant { - public virtual Guid? TenantId { get; protected set; } + public Guid? TenantId { get; protected set; } public string Name { get; set; } = default!; - public string? Description { get; set; } + public int VersionNumber { get; set; } - public PromptType Type { get; set; } + public string SystemPrompt { get; set; } = default!; - public bool IsActive { get; set; } = true; + public string UserPrompt { get; set; } = default!; + + public string MetadataJson { get; set; } = "{}"; - public ICollection Versions { get; set; } = new List(); + public bool IsActive { get; set; } = true; protected AIPrompt() { } - public AIPrompt(Guid id, string name, PromptType type, Guid? tenantId = null) + public AIPrompt( + Guid id, + string name, + int versionNumber, + string systemPrompt, + string userPrompt, + Guid? tenantId = null) { Id = id; Name = name; - Type = type; + VersionNumber = versionNumber; + SystemPrompt = systemPrompt; + UserPrompt = userPrompt; + MetadataJson = "{}"; TenantId = tenantId; IsActive = true; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs deleted file mode 100644 index 440aec6e0c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Domain/AIPromptVersion.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Volo.Abp.Domain.Entities.Auditing; -using Volo.Abp.MultiTenancy; - -namespace Unity.AI.Domain; - -public class AIPromptVersion : AuditedAggregateRoot, IMultiTenant -{ - public virtual Guid? TenantId { get; protected set; } - - public Guid PromptId { get; set; } - public AIPrompt? Prompt { get; set; } - - public int VersionNumber { get; set; } - - public string SystemPrompt { get; set; } = default!; - public string UserPromptTemplate { get; set; } = default!; - public string? DeveloperNotes { get; set; } - - public string? TargetModel { get; set; } - public string? TargetProvider { get; set; } - - public double Temperature { get; set; } = 0.2; - public int? MaxTokens { get; set; } - - public bool IsPublished { get; set; } - public bool IsDeprecated { get; set; } - - /// Optional JSON metadata for extensibility (stored as Postgres jsonb). - public string? MetadataJson { get; set; } - - protected AIPromptVersion() { } - - public AIPromptVersion( - Guid id, - Guid promptId, - int versionNumber, - string systemPrompt, - string userPromptTemplate, - Guid? tenantId = null) - { - Id = id; - PromptId = promptId; - VersionNumber = versionNumber; - SystemPrompt = systemPrompt; - UserPromptTemplate = userPromptTemplate; - TenantId = tenantId; - Temperature = 0.2; - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs index a23dae6f48..7184e09c74 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/EntityFrameworkCore/AIDbContextModelCreatingExtensions.cs @@ -21,41 +21,83 @@ public static void ConfigureAI(this ModelBuilder modelBuilder) .IsRequired() .HasMaxLength(200); - b.Property(x => x.Description) - .HasMaxLength(2000); + b.Property(x => x.VersionNumber) + .IsRequired(); + + b.Property(x => x.SystemPrompt) + .IsRequired() + .HasColumnType("text"); + + b.Property(x => x.UserPrompt) + .IsRequired() + .HasColumnType("text"); - b.Property(x => x.Type) + b.Property(x => x.MetadataJson) + .IsRequired() + .HasColumnType("jsonb") + .HasDefaultValue("{}"); + + b.Property(x => x.IsActive) .IsRequired(); - b.HasIndex(x => x.Name) + b.HasIndex(x => new { x.TenantId, x.Name, x.VersionNumber }) .IsUnique(); + }); + + modelBuilder.Entity(b => + { + b.ToTable(AIDbProperties.DbTablePrefix + "AIModels", AIDbProperties.DbSchema); + + b.ConfigureByConvention(); + + b.Property(x => x.Name) + .IsRequired() + .HasMaxLength(200); - b.HasMany(x => x.Versions) - .WithOne(x => x.Prompt) - .HasForeignKey(x => x.PromptId) - .OnDelete(DeleteBehavior.Cascade); + b.Property(x => x.IsActive) + .IsRequired(); + + b.Property(x => x.SettingsJson) + .IsRequired() + .HasColumnType("jsonb"); + + b.HasIndex(x => x.Name) + .IsUnique(); }); - modelBuilder.Entity(b => + modelBuilder.Entity(b => { - b.ToTable(AIDbProperties.DbTablePrefix + "AIPromptVersions", AIDbProperties.DbSchema); + b.ToTable(AIDbProperties.DbTablePrefix + "AIOperations", AIDbProperties.DbSchema); b.ConfigureByConvention(); - b.Property(x => x.SystemPrompt).IsRequired().HasColumnType("text"); - b.Property(x => x.UserPromptTemplate).IsRequired().HasColumnType("text"); + b.Property(x => x.Name) + .IsRequired() + .HasMaxLength(200); - b.Property(x => x.TargetModel) - .HasMaxLength(100); + b.Property(x => x.ExecutionMode) + .IsRequired() + .HasConversion() + .HasMaxLength(20); - b.Property(x => x.TargetProvider) - .HasMaxLength(100); + b.Property(x => x.CompletionTokens) + .IsRequired(); - b.Property(x => x.MetadataJson) - .HasColumnType("jsonb"); + b.Property(x => x.IsActive) + .IsRequired(); - b.HasIndex(x => new { x.PromptId, x.VersionNumber }) + b.HasIndex(x => x.Name) .IsUnique(); + + b.HasOne(x => x.AIModel) + .WithMany() + .HasForeignKey(x => x.AIModelId) + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne(x => x.AIPrompt) + .WithMany() + .HasForeignKey(x => x.AIPromptId) + .OnDelete(DeleteBehavior.Restrict); }); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs index d814818c6b..443b3602cc 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/AIGenerationAppService.cs @@ -9,21 +9,29 @@ using Unity.AI.Localization; using Unity.AI.Operations; using Unity.AI.Permissions; +using Unity.AI.RateLimit; using Unity.AI.Settings; using Unity.GrantManager.Attachments; using Unity.GrantManager.GrantApplications; using Volo.Abp.MultiTenancy; +using Volo.Abp; +using Volo.Abp.Features; namespace Unity.AI.Generation; [Route("api/app/ai/generation")] public class AIGenerationAppService( - IAttachmentSummaryService attachmentSummaryService, IApplicationAIGenerationQueue aiGenerationQueue, + IAIGenerationStatusAppService aiGenerationStatusAppService, + IAIRateLimiter aiRateLimiter, AIFeatureGuard featureGuard, ICurrentTenant currentTenant) : AIAppService, IAIGenerationAppService { + private const string ApplicationAnalysisOperationType = "application-analysis"; + private const string AttachmentSummaryOperationType = "attachment-summary"; + private const string ApplicationScoringOperationType = "application-scoring"; + [Authorize(AIPermissions.Analysis.GenerateAttachmentSummaries)] [HttpPost("attachment-summary")] public virtual async Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input) @@ -37,12 +45,15 @@ await featureGuard.EnsureEnabledAsync( return []; } - var summaries = await attachmentSummaryService.GenerateForApplicationAsync( + await aiGenerationQueue.QueueAttachmentSummaryAsync( input.ApplicationId, + currentTenant.Id, input.PromptVersion, input.AttachmentIds); - return summaries.Select(_ => new AttachmentSummaryResultDto { Completed = true }).ToList(); + return input.AttachmentIds + .Select(_ => new AttachmentSummaryResultDto { Completed = false }) + .ToList(); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] @@ -69,18 +80,56 @@ await featureGuard.EnsureEnabledAsync( return new ApplicationScoringResultDto { Completed = false }; } - [Authorize(AIPermissions.Analysis.ViewAttachmentSummary)] - [Authorize(AIPermissions.Analysis.ViewApplicationAnalysis)] - [Authorize(AIPermissions.Analysis.ViewScoringResult)] - [HttpPost("all")] - public virtual async Task GenerateContentAsync(Guid applicationId, string? promptVersion = null) + [Authorize] + [HttpGet("status")] + public virtual async Task GetStatusAsync(Guid applicationId, string operationType) + { + await EnsureStatusAccessAsync(operationType); + + var request = await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, currentTenant.Id); + var state = await aiRateLimiter.GetStateAsync(); + + return new AIGenerationStatusDto + { + GenerationRequest = request == null + ? null + : new AIGenerationStatusRequestDto + { + Id = request.Id, + ApplicationId = request.ApplicationId, + OperationId = request.OperationId, + OperationType = operationType, + Status = request.Status.ToString(), + StartedAt = request.StartedAt, + CompletedAt = request.CompletedAt, + FailureReason = request.FailureReason, + IsActive = request.IsActive + }, + FailureReason = request?.FailureReason, + IsGenerating = state.IsGenerating, + RetryAfterSeconds = state.RetryAfterSeconds + }; + } + + private async Task EnsureStatusAccessAsync(string operationType) { - await featureGuard.EnsureEnabledAsync(AIFeatures.AttachmentSummaries, AILocalizationKeys.GenerateAllDisabled); - await featureGuard.EnsureEnabledAsync(AIFeatures.ApplicationAnalysis, AILocalizationKeys.GenerateAllDisabled); - await featureGuard.EnsureEnabledAsync(AIFeatures.Scoring, AILocalizationKeys.GenerateAllDisabled); + var permission = operationType switch + { + ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, + AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, + ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, + AIGenerationRequestKeyHelper.PipelineOperationType => null, + _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}") + }; - await aiGenerationQueue.QueueAllAIStagesAsync(applicationId, currentTenant.Id, promptVersion); + if (permission is null) + { + await CheckPolicyAsync(AIPermissions.Analysis.ViewApplicationAnalysis); + await CheckPolicyAsync(AIPermissions.Analysis.ViewAttachmentSummary); + await CheckPolicyAsync(AIPermissions.Analysis.ViewScoringResult); + return; + } - return new ApplicationContentResultDto { Completed = true }; + await CheckPolicyAsync(permission); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs index e7f11e6c33..0d6c9b02f9 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptAppService.cs @@ -1,12 +1,17 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Unity.AI.Domain; using Unity.Modules.Shared.Permissions; +using Volo.Abp.Data; using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; using Volo.Abp.Domain.Repositories; +using Volo.Abp.MultiTenancy; +using Volo.Abp; namespace Unity.AI.Prompts; @@ -21,9 +26,14 @@ public class AIPromptAppService : CreateUpdateAIPromptDto>, IAIPromptAppService { - public AIPromptAppService(IRepository repository) + private readonly IDataFilter _multiTenantDataFilter; + + public AIPromptAppService( + IRepository repository, + IDataFilter multiTenantDataFilter) : base(repository) { + _multiTenantDataFilter = multiTenantDataFilter; GetPolicyName = IdentityConsts.ITOperationsPolicyName; GetListPolicyName = IdentityConsts.ITOperationsPolicyName; CreatePolicyName = IdentityConsts.ITOperationsPolicyName; @@ -31,10 +41,23 @@ public AIPromptAppService(IRepository repository) DeletePolicyName = IdentityConsts.ITOperationsPolicyName; } + [HttpGet("by-prompt/{promptId}")] + public virtual async Task> GetByPromptAsync(Guid promptId) + { + using (_multiTenantDataFilter.Disable()) + { + var selected = await Repository.GetAsync(promptId); + var items = await Repository.GetListAsync(v => v.TenantId == selected.TenantId && v.Name == selected.Name); + var sorted = items.OrderBy(v => v.VersionNumber).ToList(); + return new ListResultDto( + ObjectMapper.Map, List>(sorted)); + } + } + [HttpGet("{id}")] public override async Task GetAsync(Guid id) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { return await base.GetAsync(id); } @@ -43,7 +66,7 @@ public override async Task GetAsync(Guid id) [HttpGet] public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { return await base.GetListAsync(input); } @@ -52,25 +75,67 @@ public override async Task> GetListAsync(PagedAndSor [HttpPost] public override async Task CreateAsync(CreateUpdateAIPromptDto input) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { - return await base.CreateAsync(input); + var prompt = await Repository.GetAsync(input.PromptId); + var existingVersion = await Repository.FirstOrDefaultAsync(p => + p.TenantId == prompt.TenantId && + p.Name == prompt.Name && + p.VersionNumber == input.VersionNumber); + if (existingVersion != null) + { + throw new UserFriendlyException( + $"AI prompt '{prompt.Name}' already has version {input.VersionNumber}."); + } + + var entity = await Repository.InsertAsync( + new AIPrompt( + Guid.CreateVersion7(), + prompt.Name, + input.VersionNumber, + input.SystemPrompt, + input.UserPrompt, + prompt.TenantId) + { + MetadataJson = string.IsNullOrWhiteSpace(input.MetadataJson) ? "{}" : input.MetadataJson, + IsActive = input.IsActive + }); + + return ObjectMapper.Map(entity); } } [HttpPut("{id}")] public override async Task UpdateAsync(Guid id, CreateUpdateAIPromptDto input) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { - return await base.UpdateAsync(id, input); + var entity = await Repository.GetAsync(id); + var conflictingVersion = await Repository.FirstOrDefaultAsync(p => + p.Id != id && + p.TenantId == entity.TenantId && + p.Name == entity.Name && + p.VersionNumber == input.VersionNumber); + if (conflictingVersion != null) + { + throw new UserFriendlyException( + $"AI prompt '{entity.Name}' already has version {input.VersionNumber}."); + } + + entity.VersionNumber = input.VersionNumber; + entity.SystemPrompt = input.SystemPrompt; + entity.UserPrompt = input.UserPrompt; + entity.MetadataJson = string.IsNullOrWhiteSpace(input.MetadataJson) ? "{}" : input.MetadataJson; + entity.IsActive = input.IsActive; + entity = await Repository.UpdateAsync(entity); + return ObjectMapper.Map(entity); } } [HttpDelete("{id}")] public override async Task DeleteAsync(Guid id) { - using (CurrentTenant.Change(null)) + using (_multiTenantDataFilter.Disable()) { await base.DeleteAsync(id); } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs deleted file mode 100644 index ff431bb7c6..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Prompts/AIPromptVersionAppService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Unity.AI.Domain; -using Unity.Modules.Shared.Permissions; -using Volo.Abp.Application.Dtos; -using Volo.Abp.Application.Services; -using Volo.Abp.Domain.Repositories; - -namespace Unity.AI.Prompts; - -[Authorize(IdentityConsts.ITOperationsPolicyName)] -[Route("api/app/ai/prompt-versions")] -public class AIPromptVersionAppService : - CrudAppService< - AIPromptVersion, - AIPromptVersionDto, - Guid, - PagedAndSortedResultRequestDto, - CreateUpdateAIPromptVersionDto>, - IAIPromptVersionAppService -{ - public AIPromptVersionAppService(IRepository repository) - : base(repository) - { - GetPolicyName = IdentityConsts.ITOperationsPolicyName; - GetListPolicyName = IdentityConsts.ITOperationsPolicyName; - CreatePolicyName = IdentityConsts.ITOperationsPolicyName; - UpdatePolicyName = IdentityConsts.ITOperationsPolicyName; - DeletePolicyName = IdentityConsts.ITOperationsPolicyName; - } - - [HttpGet("by-prompt/{promptId}")] - public async Task> GetByPromptAsync(Guid promptId) - { - using (CurrentTenant.Change(null)) - { - var items = await Repository.GetListAsync(v => v.PromptId == promptId); - var sorted = items.OrderBy(v => v.VersionNumber).ToList(); - return new ListResultDto( - ObjectMapper.Map, List>(sorted)); - } - } - - [HttpGet("{id}")] - public override async Task GetAsync(Guid id) - { - using (CurrentTenant.Change(null)) - { - return await base.GetAsync(id); - } - } - - [HttpGet] - public override async Task> GetListAsync(PagedAndSortedResultRequestDto input) - { - using (CurrentTenant.Change(null)) - { - return await base.GetListAsync(input); - } - } - - [HttpPost] - public override async Task CreateAsync(CreateUpdateAIPromptVersionDto input) - { - using (CurrentTenant.Change(null)) - { - return await base.CreateAsync(input); - } - } - - [HttpPut("{id}")] - public override async Task UpdateAsync(Guid id, CreateUpdateAIPromptVersionDto input) - { - using (CurrentTenant.Change(null)) - { - return await base.UpdateAsync(id, input); - } - } - - [HttpDelete("{id}")] - public override async Task DeleteAsync(Guid id) - { - using (CurrentTenant.Change(null)) - { - await base.DeleteAsync(id); - } - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs index df2d32a605..f7bca0af75 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Settings/AIConfigurationAppService.cs @@ -18,6 +18,7 @@ public class AIConfigurationAppService( private readonly ISettingManager _settingManager = settingManager; private readonly ICurrentTenant _currentTenant = currentTenant; + [Authorize(AIPermissions.Configuration.ConfigureAI)] [HttpGet("tenant")] public virtual async Task GetTenantConfigurationAsync() { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json index aef66e58de..3f72aff220 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AI/en.json @@ -28,25 +28,20 @@ "AI:ApplicationAnalysisRequiresSubmission": "AI application analysis requires application submission data.", "AI:ScoringRequiresScoresheet": "AI scoring requires a configured scoresheet.", "AI:ScoringRequiresScoresheetFields": "AI scoring requires a scoresheet with scoring fields.", + "AI:AttachmentNotFound": "Attachment not found.", "AI:SelectAttachmentForSummaries": "Select at least one attachment to generate summaries.", "AIPrompts": "AI Prompts", "AIPrompt": "AI Prompt", "AIPromptVersion": "Prompt Version", "AIPromptVersions": "Prompt Versions", - "PromptType": "Type", "PromptName": "Name", - "PromptDescription": "Description", "PromptIsActive": "Active", "VersionNumber": "Version Number", "SystemPrompt": "System Prompt", - "UserPromptTemplate": "User Prompt Template", - "DeveloperNotes": "Developer Notes", - "TargetModel": "Target Model", - "TargetProvider": "Target Provider", + "UserPrompt": "User Prompt", + "MetadataJson": "Metadata JSON", "Temperature": "Temperature", - "MaxTokens": "Max Tokens", - "IsPublished": "Published", - "IsDeprecated": "Deprecated" + "MaxTokens": "Max Tokens" } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs index 2fdcdbd2e1..07b526d9a4 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Localization/AILocalizationKeys.cs @@ -10,5 +10,6 @@ public static class AILocalizationKeys public const string ApplicationAnalysisRequiresSubmission = "AI:ApplicationAnalysisRequiresSubmission"; public const string ScoringRequiresScoresheet = "AI:ScoringRequiresScoresheet"; public const string ScoringRequiresScoresheetFields = "AI:ScoringRequiresScoresheetFields"; + public const string AttachmentNotFound = "AI:AttachmentNotFound"; public const string SelectAttachmentForSummaries = "AI:SelectAttachmentForSummaries"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs index bf7f06d896..50eea177db 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenuContributor.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Unity.AI.Localization; using Unity.AI.Permissions; +using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; using Volo.Abp.Features; using Volo.Abp.UI.Navigation; @@ -23,14 +24,18 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex var l = context.GetLocalizer(); var featureChecker = context.ServiceProvider.GetRequiredService(); - context.Menu.AddItem(new ApplicationMenuItem( - name: AIMenus.Prompts, - displayName: "AI Prompts", - url: "~/Prompts", - icon: "fl fl-ai-prompts", - order: 900, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName - )); + var specializationChecker = context.ServiceProvider.GetRequiredService(); + if (!await specializationChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) + { + context.Menu.AddItem(new ApplicationMenuItem( + name: AIMenus.Prompts, + displayName: "AI Prompts", + url: "~/Prompts", + icon: "fl fl-ai-prompts", + order: 900, + requiredPermissionName: IdentityConsts.ITOperationsPermissionName + )); + } if (await featureChecker.IsEnabledAsync("Unity.AIReporting")) { diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs index 1279b7a691..b99da8ecf8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/EditModal.cshtml.cs @@ -27,9 +27,10 @@ public async Task OnGetAsync() var dto = await _promptAppService.GetAsync(Id); Prompt = new CreateUpdateAIPromptDto { - Name = dto.Name, - Description = dto.Description, - Type = dto.Type, + VersionNumber = dto.VersionNumber, + SystemPrompt = dto.SystemPrompt, + UserPrompt = dto.UserPrompt, + MetadataJson = dto.MetadataJson, IsActive = dto.IsActive }; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml similarity index 70% rename from applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml index 38acee10e0..85da100816 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml @@ -1,17 +1,17 @@ @page @using Unity.AI.Localization -@using Unity.AI.Web.Pages.Prompts.Versions +@using Unity.AI.Web.Pages.Prompts.Entries @using Microsoft.Extensions.Localization @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal -@model CreateVersionModalModel +@model CreateEntryModalModel @inject IStringLocalizer L @{ Layout = null; } - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml.cs new file mode 100644 index 0000000000..ee1e53654a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/CreateEntryModal.cshtml.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts.Entries; + +public class CreateEntryModalModel : AbpPageModel +{ + [BindProperty] + public CreateUpdateAIPromptDto Prompt { get; set; } = new(); + + private readonly IAIPromptAppService _promptAppService; + + public CreateEntryModalModel(IAIPromptAppService promptAppService) + { + _promptAppService = promptAppService; + } + + public void OnGet(Guid promptId) + { + Prompt = new CreateUpdateAIPromptDto + { + PromptId = promptId + }; + } + + public async Task OnPostAsync() + { + await _promptAppService.CreateAsync(Prompt); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml similarity index 72% rename from applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml rename to applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml index 2f1e96ea4f..6ca56cf1c7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml @@ -1,17 +1,17 @@ @page @using Unity.AI.Localization -@using Unity.AI.Web.Pages.Prompts.Versions +@using Unity.AI.Web.Pages.Prompts.Entries @using Microsoft.Extensions.Localization @using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal -@model EditVersionModalModel +@model EditEntryModalModel @inject IStringLocalizer L @{ Layout = null; } - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml.cs new file mode 100644 index 0000000000..b2f2a5dcc0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Entries/EditEntryModal.cshtml.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading.Tasks; +using Unity.AI.Prompts; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.AI.Web.Pages.Prompts.Entries; + +public class EditEntryModalModel : AbpPageModel +{ + [HiddenInput] + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + [BindProperty] + public CreateUpdateAIPromptDto Prompt { get; set; } = new(); + + private readonly IAIPromptAppService _promptAppService; + + public EditEntryModalModel(IAIPromptAppService promptAppService) + { + _promptAppService = promptAppService; + } + + public async Task OnGetAsync() + { + var dto = await _promptAppService.GetAsync(Id); + Prompt = new CreateUpdateAIPromptDto + { + PromptId = dto.Id, + VersionNumber = dto.VersionNumber, + SystemPrompt = dto.SystemPrompt, + UserPrompt = dto.UserPrompt, + MetadataJson = dto.MetadataJson, + IsActive = dto.IsActive + }; + } + + public async Task OnPostAsync() + { + await _promptAppService.UpdateAsync(Id, Prompt); + return NoContent(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml index 0099f5cf0e..1b4d2bddd8 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.cshtml @@ -67,36 +67,6 @@
-
- - -
-
- - -
- -
- - -
-
- - -
-
-
- - -
-
-
-
- - -
-
-
@@ -104,26 +74,28 @@
- - -
User Prompt Template is required.
+ + +
User Prompt is required.
-
- - +
+
+ + +
- + jsonb
- +
diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js index 28062f58cf..07f1c714c1 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Index.js @@ -1,7 +1,7 @@ $(function () { const l = abp.localization.getResource('AI'); - // Prompt-level modals (create / edit prompt metadata only) + // Prompt-level modals (create / edit prompt rows) let createModal = new abp.ModalManager(abp.appPath + 'Prompts/CreateModal'); let editModal = new abp.ModalManager(abp.appPath + 'Prompts/EditModal'); @@ -19,27 +19,32 @@ $(function () { index: 0 }, { - title: l('PromptType'), - name: 'type', - data: 'type', + title: l('VersionNumber'), + name: 'versionNumber', + data: 'versionNumber', index: 1, - render: (data) => { - const types = ['Orchestrator', 'Skill', 'Instruction', 'Agent']; - return types[data] ?? data; - } }, { - title: l('PromptDescription'), - name: 'description', - data: 'description', + title: l('SystemPrompt'), + name: 'systemPrompt', + data: 'systemPrompt', index: 2, - defaultContent: '' + defaultContent: '', + render: (data) => (data ?? '').slice(0, 80) + }, + { + title: l('UserPrompt'), + name: 'userPrompt', + data: 'userPrompt', + index: 3, + defaultContent: '', + render: (data) => (data ?? '').slice(0, 80) }, { title: l('PromptIsActive'), name: 'isActive', data: 'isActive', - index: 3, + index: 4, render: (data) => data ? 'Active' : 'Inactive' @@ -50,7 +55,7 @@ $(function () { orderable: false, className: 'text-center', name: 'rowActions', - index: 4, + index: 5, rowAction: { items: [ { @@ -78,7 +83,7 @@ $(function () { } ]; - const defaultVisibleColumns = ['name', 'type', 'description', 'isActive', 'rowActions']; + const defaultVisibleColumns = ['name', 'versionNumber', 'systemPrompt', 'userPrompt', 'isActive', 'rowActions']; const dt = $('#AIPromptsTable'); const dataTable = initializeDataTable({ @@ -132,7 +137,7 @@ $(function () { } function loadVersions(promptId) { - unity.aI.prompts.aIPromptVersion.getByPrompt(promptId).then(function (result) { + unity.aI.prompts.aIPrompt.getByPrompt(promptId).then(function (result) { cachedVersions = result.items || []; const $select = $('#versionSelect'); $select.empty(); @@ -166,7 +171,7 @@ $(function () { populateVersionForm(v); } else { // fallback: fetch from server - unity.aI.prompts.aIPromptVersion.get(id).then(populateVersionForm); + unity.aI.prompts.aIPrompt.get(id).then(populateVersionForm); } }); @@ -177,15 +182,9 @@ $(function () { $('#versionId').val(v.id); $('#versionNumber').val(v.versionNumber); - $('#versionTargetModel').val(v.targetModel ?? ''); - $('#versionTargetProvider').val(v.targetProvider ?? ''); - $('#versionTemperature').val(v.temperature ?? 0.2); - $('#versionMaxTokens').val(v.maxTokens ?? ''); - $('#versionIsPublished').prop('checked', v.isPublished ?? false); - $('#versionIsDeprecated').prop('checked', v.isDeprecated ?? false); $('#versionSystemPrompt').val(v.systemPrompt ?? '').removeClass('is-invalid'); - $('#versionUserPromptTemplate').val(v.userPromptTemplate ?? '').removeClass('is-invalid'); - $('#versionDeveloperNotes').val(v.developerNotes ?? ''); + $('#versionUserPrompt').val(v.userPrompt ?? '').removeClass('is-invalid'); + $('#versionIsActive').prop('checked', v.isActive ?? true); // Pretty-print MetadataJson if valid let meta = v.metadataJson ?? ''; @@ -204,15 +203,9 @@ $(function () { currentVersionId = null; $('#versionId').val(''); - $('#versionTargetModel').val(''); - $('#versionTargetProvider').val(''); - $('#versionTemperature').val(0.2); - $('#versionMaxTokens').val(''); - $('#versionIsPublished').prop('checked', false); - $('#versionIsDeprecated').prop('checked', false); $('#versionSystemPrompt').val(''); - $('#versionUserPromptTemplate').val(''); - $('#versionDeveloperNotes').val(''); + $('#versionUserPrompt').val(''); + $('#versionIsActive').prop('checked', true); $('#versionMetadataJson').val(''); clearJsonError(); @@ -239,8 +232,8 @@ $(function () { if (!promptId) return; // Required-field validation - const systemPrompt = $('#versionSystemPrompt').val().trim(); - const userPromptTemplate = $('#versionUserPromptTemplate').val().trim(); + const systemPrompt = $('#versionSystemPrompt').val().trim(); + const userPrompt = $('#versionUserPrompt').val().trim(); let valid = true; if (systemPrompt) { $('#versionSystemPrompt').removeClass('is-invalid'); @@ -248,14 +241,14 @@ $(function () { $('#versionSystemPrompt').addClass('is-invalid'); valid = false; } - if (userPromptTemplate) { - $('#versionUserPromptTemplate').removeClass('is-invalid'); + if (userPrompt) { + $('#versionUserPrompt').removeClass('is-invalid'); } else { - $('#versionUserPromptTemplate').addClass('is-invalid'); + $('#versionUserPrompt').addClass('is-invalid'); valid = false; } if (!valid) { - $('#versionSystemPrompt.is-invalid, #versionUserPromptTemplate.is-invalid')[0]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + $('#versionSystemPrompt.is-invalid, #versionUserPrompt.is-invalid')[0]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); return; } @@ -263,25 +256,19 @@ $(function () { if (metaRaw && !validateJson(metaRaw)) return; const dto = { - promptId: promptId, - versionNumber: Number.parseInt($('#versionNumber').val()) || 0, - systemPrompt: systemPrompt, - userPromptTemplate: userPromptTemplate, - developerNotes: $('#versionDeveloperNotes').val() || null, - targetModel: $('#versionTargetModel').val() || null, - targetProvider: $('#versionTargetProvider').val() || null, - temperature: Number.parseFloat($('#versionTemperature').val()) || 0.2, - maxTokens: $('#versionMaxTokens').val() ? Number.parseInt($('#versionMaxTokens').val()) : null, - isPublished: $('#versionIsPublished').is(':checked'), - isDeprecated: $('#versionIsDeprecated').is(':checked'), - metadataJson: metaRaw || null + promptId: promptId, + versionNumber: Number.parseInt($('#versionNumber').val()) || 0, + systemPrompt: systemPrompt, + userPrompt: userPrompt, + metadataJson: metaRaw || null, + isActive: $('#versionIsActive').is(':checked') }; if (isNewVersion) { const newOpt = $('#versionSelect option[data-new]'); dto.versionNumber = newOpt.length ? Number.parseInt(newOpt.data('num')) : 0; - unity.aI.prompts.aIPromptVersion.create(dto) + unity.aI.prompts.aIPrompt.create(dto) .then(function () { abp.notify.success('Version created'); loadVersions(promptId); @@ -290,7 +277,7 @@ $(function () { abp.notify.error(err?.message || 'Failed to create version'); }); } else { - unity.aI.prompts.aIPromptVersion.update(currentVersionId, dto) + unity.aI.prompts.aIPrompt.update(currentVersionId, dto) .then(function () { abp.notify.success('Version saved'); loadVersions(promptId); diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs deleted file mode 100644 index ba49f50d0a..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/CreateVersionModal.cshtml.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading.Tasks; -using Unity.AI.Prompts; -using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; - -namespace Unity.AI.Web.Pages.Prompts.Versions; - -public class CreateVersionModalModel : AbpPageModel -{ - [BindProperty] - public CreateUpdateAIPromptVersionDto Version { get; set; } = new(); - - private readonly IAIPromptVersionAppService _versionAppService; - - public CreateVersionModalModel(IAIPromptVersionAppService versionAppService) - { - _versionAppService = versionAppService; - } - - public void OnGet(Guid promptId) - { - Version = new CreateUpdateAIPromptVersionDto - { - PromptId = promptId, - Temperature = 0.2 - }; - } - - public async Task OnPostAsync() - { - await _versionAppService.CreateAsync(Version); - return NoContent(); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs deleted file mode 100644 index 40390dd976..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/Prompts/Versions/EditVersionModal.cshtml.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading.Tasks; -using Unity.AI.Prompts; -using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; - -namespace Unity.AI.Web.Pages.Prompts.Versions; - -public class EditVersionModalModel : AbpPageModel -{ - [HiddenInput] - [BindProperty(SupportsGet = true)] - public Guid Id { get; set; } - - [BindProperty] - public CreateUpdateAIPromptVersionDto Version { get; set; } = new(); - - private readonly IAIPromptVersionAppService _versionAppService; - - public EditVersionModalModel(IAIPromptVersionAppService versionAppService) - { - _versionAppService = versionAppService; - } - - public async Task OnGetAsync() - { - var dto = await _versionAppService.GetAsync(Id); - Version = new CreateUpdateAIPromptVersionDto - { - PromptId = dto.PromptId, - VersionNumber = dto.VersionNumber, - SystemPrompt = dto.SystemPrompt, - UserPromptTemplate = dto.UserPromptTemplate, - DeveloperNotes = dto.DeveloperNotes, - TargetModel = dto.TargetModel, - TargetProvider = dto.TargetProvider, - Temperature = dto.Temperature, - MaxTokens = dto.MaxTokens, - IsPublished = dto.IsPublished, - IsDeprecated = dto.IsDeprecated, - MetadataJson = dto.MetadataJson - }; - } - - public async Task OnPostAsync() - { - await _versionAppService.UpdateAsync(Id, Version); - return NoContent(); - } -} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs index c19a67de98..0463a2d8ff 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/IWorksheetInstanceAppService.cs @@ -1,5 +1,7 @@ -using System.Threading.Tasks; -using System; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Services; namespace Unity.Flex.WorksheetInstances @@ -9,5 +11,10 @@ public interface IWorksheetInstanceAppService : IApplicationService Task GetByCorrelationAnchorAsync(Guid correlationId, string correlationProvider, Guid worksheetId, string uiAnchor); Task CreateAsync(CreateWorksheetInstanceDto dto); Task UpdateAsync(PersistWorksheetIntanceValuesDto dto); + Task> GetListByCorrelationIdsAsync(List correlationIds, string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(List correlationIds, string correlationProvider); + Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount); + Task GetDataByIdAsync(Guid id); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/WorksheetInstanceDataDto.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/WorksheetInstanceDataDto.cs new file mode 100644 index 0000000000..4255e6135d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application.Contracts/WorksheetInstances/WorksheetInstanceDataDto.cs @@ -0,0 +1,13 @@ +using System; + +namespace Unity.Flex.WorksheetInstances +{ + public class WorksheetInstanceDataDto + { + public Guid Id { get; set; } + public Guid CorrelationId { get; set; } + public Guid WorksheetId { get; set; } + public string CurrentValue { get; set; } = "{}"; + public DateTime CreationTime { get; set; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs index 4201df50ed..f6399f2f1a 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Domain/WorksheetInstances/IWorksheetInstanceRepository.cs @@ -12,5 +12,10 @@ public interface IWorksheetInstanceRepository : IBasicRepository GetWithValuesAsync(Guid worksheetInstanceId); Task ExistsAsync(Guid worksheetId, Guid instanceCorrelationId, string instanceCorrelationProvider, Guid sheetCorrelationId, string sheetCorrelationProvider, string? uiAnchor); Task AnyByWorksheetAndFormVersionAsync(Guid worksheetId, Guid formVersionId); + Task> GetByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider); + Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider); + Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount); + Task GetCountByCorrelationProviderAsync(string correlationProvider); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs index 71edcca8b6..60fdfb448f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/EntityFrameworkCore/Repositories/WorksheetInstanceRepository.cs @@ -76,5 +76,52 @@ public async Task AnyByWorksheetAndFormVersionAsync(Guid worksheetId, Guid && s.WorksheetCorrelationId == formVersionId && s.WorksheetCorrelationProvider == CorrelationConsts.FormVersion); } + + public async Task> GetByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + var ids = correlationIds.ToList(); + return await dbSet + .Where(wi => ids.Contains(wi.CorrelationId) && wi.CorrelationProvider == correlationProvider) + .ToListAsync(); + } + + public async Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(wi => wi.CorrelationProvider == correlationProvider) + .Select(wi => wi.WorksheetId) + .Distinct() + .ToListAsync(); + } + + public async Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(IEnumerable correlationIds, string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + var ids = correlationIds.ToList(); + return await dbSet + .Where(wi => ids.Contains(wi.CorrelationId) && wi.CorrelationProvider == correlationProvider) + .Select(wi => wi.WorksheetId) + .Distinct() + .ToListAsync(); + } + + public async Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount) + { + var dbSet = await GetDbSetAsync(); + return await dbSet + .Where(wi => wi.CorrelationProvider == correlationProvider) + .OrderByDescending(wi => wi.CreationTime) + .Skip(skipCount) + .Take(maxResultCount) + .ToListAsync(); + } + + public async Task GetCountByCorrelationProviderAsync(string correlationProvider) + { + var dbSet = await GetDbSetAsync(); + return await dbSet.CountAsync(wi => wi.CorrelationProvider == correlationProvider); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs index da2c963a19..7c760c2560 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Application/Worksheets/WorksheetInstanceAppService.cs @@ -1,8 +1,12 @@ -using System.Threading.Tasks; -using System; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Unity.Flex.Domain.WorksheetInstances; using Unity.Flex.Domain.Services; +using Volo.Abp; +using Volo.Abp.Application.Dtos; namespace Unity.Flex.WorksheetInstances { @@ -29,5 +33,60 @@ public virtual async Task UpdateAsync(PersistWorksheetIntanceValuesDto dto) { await worksheetsManager.PersistWorksheetData(ObjectMapper.Map(dto)); } + + [RemoteService(false)] + public virtual async Task> GetListByCorrelationIdsAsync(List correlationIds, string correlationProvider) + { + var instances = await worksheetInstanceRepository.GetByCorrelationIdsAsync(correlationIds, correlationProvider); + return instances.Select(wi => new WorksheetInstanceDataDto + { + CorrelationId = wi.CorrelationId, + WorksheetId = wi.WorksheetId, + CurrentValue = wi.CurrentValue + }).ToList(); + } + + [RemoteService(false)] + public virtual async Task> GetDistinctWorksheetIdsByCorrelationProviderAsync(string correlationProvider) + { + return await worksheetInstanceRepository.GetDistinctWorksheetIdsByCorrelationProviderAsync(correlationProvider); + } + + [RemoteService(false)] + public virtual async Task> GetDistinctWorksheetIdsByCorrelationIdsAsync(List correlationIds, string correlationProvider) + { + return await worksheetInstanceRepository.GetDistinctWorksheetIdsByCorrelationIdsAsync(correlationIds, correlationProvider); + } + + [RemoteService(false)] + public virtual async Task> GetPagedListByCorrelationProviderAsync(string correlationProvider, int skipCount, int maxResultCount) + { + var totalCount = await worksheetInstanceRepository.GetCountByCorrelationProviderAsync(correlationProvider); + var instances = await worksheetInstanceRepository.GetPagedListByCorrelationProviderAsync(correlationProvider, skipCount, maxResultCount); + var items = instances.Select(wi => new WorksheetInstanceDataDto + { + Id = wi.Id, + CorrelationId = wi.CorrelationId, + WorksheetId = wi.WorksheetId, + CurrentValue = wi.CurrentValue, + CreationTime = wi.CreationTime + }).ToList(); + return new PagedResultDto(totalCount, items); + } + + [RemoteService(false)] + public virtual async Task GetDataByIdAsync(Guid id) + { + var wi = await worksheetInstanceRepository.FindAsync(id); + if (wi == null) return null; + return new WorksheetInstanceDataDto + { + Id = wi.Id, + CorrelationId = wi.CorrelationId, + WorksheetId = wi.WorksheetId, + CurrentValue = wi.CurrentValue, + CreationTime = wi.CreationTime + }; + } } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs index 6a1440df97..7489f231e2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Pages/Components/DataGrid/DataGridWriteService.cs @@ -279,6 +279,22 @@ internal async Task UpdateRowAsync(RowInputData rowInputDa }; } + internal async Task DeleteRowAsync(Guid valueId, uint row, Guid worksheetInstanceId) + { + var currentValue = await customFieldValueAppService.GetAsync(valueId); + var dataGridValue = DataGridServiceUtils.DeserializeDataGridValue(currentValue.CurrentValue); + if (dataGridValue == null) return; + + var dataGridRowsValue = DataGridServiceUtils.DeserializeDataGridRowsValue(dataGridValue.Value?.ToString()); + if (dataGridRowsValue == null || row >= (uint)dataGridRowsValue.Rows.Count) return; + + dataGridRowsValue.Rows.RemoveAt((int)row); + dataGridValue.Value = dataGridRowsValue; + + await customFieldValueAppService.ExplicitSetAsync(valueId, JsonSerializer.Serialize(dataGridValue)); + await customFieldValueAppService.SyncWorksheetInstanceValueAsync(worksheetInstanceId); + } + internal async Task>> GenerateKeyValueTypesAsync(Guid customFieldId, Dictionary? keyValuePairs) { var result = new List>(); diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj index 349cb06a44..1b9333d47f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Unity.Flex.Web.csproj @@ -43,6 +43,10 @@ + + + + true diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidgetController.cs b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidgetController.cs index e128b73cdb..9b265278cf 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidgetController.cs +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/DataGridWidget/DataGridWidgetController.cs @@ -1,14 +1,22 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; +using System.Threading.Tasks; +using Unity.Flex.Web.Pages.Flex; using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels; +using Unity.Flex.Worksheets; +using Unity.Flex.WorksheetInstances; using Volo.Abp.AspNetCore.Mvc; namespace Unity.Flex.Web.Views.Shared.Components.DataGridWidget { [ApiExplorerSettings(IgnoreApi = true)] [Route("Flex/Widgets/DataGrid")] - public class DataGridWidgetController : AbpController + public class DataGridWidgetController( + ICustomFieldAppService customFieldAppService, + ICustomFieldValueAppService customFieldValueAppService, + DataGridWriteService dataGridWriteService) : AbpController { [HttpGet] [Route("Refresh")] @@ -33,5 +41,54 @@ public IActionResult Refresh(WorksheetFieldViewModel? fieldModel, worksheetInstanceId }); } + + [Authorize] + [HttpPost] + [Route("DeleteRow")] + public async Task DeleteRow( + Guid valueId, + uint row, + Guid worksheetInstanceId) + { + await dataGridWriteService.DeleteRowAsync(valueId, row, worksheetInstanceId); + return new OkObjectResult(new { row, worksheetInstanceId }); + } + + [Authorize] + [HttpGet] + [Route("RefreshByField")] + public async Task RefreshByField( + Guid valueId, + Guid fieldId, + string modelName, + Guid worksheetId, + Guid worksheetInstanceId, + string uiAnchor) + { + var field = await customFieldAppService.GetAsync(fieldId); + var value = await customFieldValueAppService.GetAsync(valueId); + + var fieldModel = new WorksheetFieldViewModel + { + Id = field.Id, + Name = field.Name, + Label = field.Label, + Type = field.Type, + Order = field.Order, + Enabled = field.Enabled, + Definition = field.Definition, + CurrentValue = value.CurrentValue, + CurrentValueId = valueId, + UiAnchor = uiAnchor + }; + + return ViewComponent(typeof(DataGridWidget), new + { + fieldModel, + modelName, + worksheetId, + worksheetInstanceId + }); + } } } 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 856b7c5a22..99348aa925 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 @@ -33,6 +33,17 @@ .custom-grid-container { display: flex; flex-direction: column; + position: relative; +} + +.grid-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.75); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; } .grid-position { @@ -61,4 +72,31 @@ color: #8a8886; margin-left: 4px; vertical-align: middle; +} + +.custom-dynamic-table .dropdown { + display: inline-block; +} + +.custom-dynamic-table .dropdown-content { + display: none; + position: fixed; + right: auto; + background-color: #f9f9f9; + min-width: 160px; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + z-index: 1039; + --bs-btn-active-color: var(--bc-colors-white-primary-500); + --bs-btn-active-bg: var(--bc-colors-blue-primary-500); +} + +.custom-dynamic-table .dropdown:hover .dropdown-content { + display: block; +} + +.custom-dynamic-table .dropdown-content .btn.fullWidth { + width: calc(100% - 16px); + display: block; + text-align: left; + margin: 10px 8px; } \ 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 7fd001ce6c..ba2d1bcb57 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 @@ -1,9 +1,61 @@ +function getDatagridActionsRowButtonTemplate(actions) { + if (actions.length === 0) return ''; + + if (actions.length === 1) { + if (actions.includes('EDIT')) + return ''; + if (actions.includes('DELETE')) + return ''; + return ''; + } + + let items = ''; + if (actions.includes('EDIT')) + items += ''; + if (actions.includes('DELETE')) + items += ''; + + return ``; +} + +// Function to set data attributes on the row +function setRowDataAttributes(row, rowIndex) { + row.attr('data-row-no', rowIndex); +} + +// Function to format currency as CAD +function formatDatagridCurrency(value) { + return new Intl.NumberFormat('en-CA', + { style: 'currency', currency: 'CAD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); +} + +// Function to check if a value is numeric +function isDatagridCellNumeric(value) { + return Number.isFinite(Number.parseFloat(value)); +} + +// Function to calculate sum for a specific column +function calculateDatagridColumnSum(table, columnIndex) { + let total = 0; + table.column(columnIndex).data().each(function (value) { + // Remove currency symbols and commas for numeric check + let cleanedValue = value.replace(/[^\d.-]/g, ''); + if (isDatagridCellNumeric(cleanedValue)) { + total += Number.parseFloat(cleanedValue); + } + }); + return total; +} + $(function () { const UIElements = { tables: $('.custom-dynamic-table'), tableSearches: $('.custom-tbl-search') }; - + let editDatagridRowModal = new abp.ModalManager({ viewUrl: '../Components/DataGrid/EditDataRowModal' }); @@ -19,23 +71,13 @@ $(function () { // Refresh any update table level attributes resetTableAttributes($(newRowNode), response); - // Create the edit button HTML and append it to the last cell of the new row - $(newRowNode).find('td:last').html(getEditRowButtonTemplate()); - - // Attach click event handler to the newly added button - $(newRowNode).find('.row-edit-btn').on('click', function () { - let button = this; // `this` refers to the button element - editDataRow(button); - }); + // Configure action buttons on the last cell of the new row + let fieldId = $(newRowNode).closest('table')[0].id; + configureActionButtonsForCell($(newRowNode).find('td:last')[0], getTableActions(fieldId)); abp.notify.success('Row added successfully.', 'New Row'); } - // Function to set data attributes on the row - function setRowDataAttributes(row, rowIndex) { - row.attr('data-row-no', rowIndex); - } - // Function to reset the table level attributes function resetTableAttributes(row, response) { let table = row.closest('table'); @@ -62,10 +104,10 @@ $(function () { function updateRow(table, dataToUpdate, rowIndex) { $.each(dataToUpdate, function (columnName, newValue) { let columnIndex = getColumnIndex(table, columnName); - if (columnIndex !== -1) { - table.cell(rowIndex, columnIndex).data(newValue); - } else { + if (columnIndex === -1) { console.warn('Column not found:', columnName); + } else { + table.cell(rowIndex, columnIndex).data(newValue); } }); @@ -105,18 +147,6 @@ $(function () { handleEditDatagridRowModalResult(response); }); - // Function to calculate sum for a specific column - function calculateColumnSum(table, columnIndex) { - let total = 0; - table.column(columnIndex).data().each(function (value) { - // Remove currency symbols and commas for numeric check - let cleanedValue = value.replace(/[^\d.-]/g, ''); - if (isNumeric(cleanedValue)) { - total += parseFloat(cleanedValue); - } - }); - return total; - } // Function to update totals function updateTotals(table, fieldId) { @@ -129,11 +159,11 @@ $(function () { let columnIndex = getColumnIndex(table, key); if (columnIndex !== -1) { - let total = calculateColumnSum(table, columnIndex); + let total = calculateDatagridColumnSum(table, columnIndex); // Update the input field with the calculated total if ($(this).data('field-type') === 'Currency') { - $(this).val(formatCurrency(total)); + $(this).val(formatDatagridCurrency(total)); } else { $(this).val(total); } @@ -141,17 +171,6 @@ $(function () { }); } - // Function to format currency as CAD - function formatCurrency(value) { - return new Intl.NumberFormat('en-CA', - { style: 'currency', currency: 'CAD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); - } - - // Function to check if a value is numeric - function isNumeric(value) { - return !isNaN(value) && isFinite(value); - } - // Function to get the index of a column by its key function getColumnIndex(table, key) { let headers = table.columns().header().toArray(); @@ -274,28 +293,37 @@ $(function () { } function configureButtons(fieldId) { - let options = ($(`#table-options-${fieldId}`).val()).split(','); + let options = new Set(($(`#table-options-${fieldId}`).val()).split(',')); // Always include ColumnVisibility button regardless of options - let availableOptions = actionButtons.filter(item => options.includes(item.id) || item.id === 'ColumnVisibility'); + let availableOptions = actionButtons.filter(item => options.has(item.id) || item.id === 'ColumnVisibility'); return availableOptions; } + function getTableActions(fieldId) { + let options = new Set(($(`#table-options-${fieldId}`).val()).split(',')); + let actions = ['EDIT']; + if (options.has('AddRecord')) actions.push('DELETE'); + return actions; + } + // Function to configure action buttons for a table cell - function configureActionButtonForCell(cell) { - cell.innerHTML = getEditRowButtonTemplate(); // Add edit button to each cell + function configureActionButtonsForCell(cell, actions) { + cell.innerHTML = getDatagridActionsRowButtonTemplate(actions); - // Attach click event handler to the newly added button $(cell).find('.row-edit-btn').on('click', function () { - let button = this; // `this` refers to the button element - editDataRow(button); + editDataRow(this); + }); + + $(cell).find('.row-delete-btn').on('click', function () { + deleteDataRow(this); }); } // Function to setup the actions column - function setupActionsColumn(table, columnIndex) { - table.column(columnIndex).header().innerHTML = 'Actions'; // Update column header if needed + function setupActionsColumn(table, columnIndex, actions) { + table.column(columnIndex).header().innerHTML = 'Actions'; table.column(columnIndex).nodes().each(function (cell) { - configureActionButtonForCell(cell); + configureActionButtonsForCell(cell, actions); }); } @@ -303,18 +331,15 @@ $(function () { // Move buttons to custom container table.buttons().container().prependTo(`#btn-container-${fieldId}`); - // Add edit buttons to the last column (Actions) + let actions = getTableActions(fieldId); + table.columns().every(function (index) { - if (index === table.columns().count() - 1) { // Check if it is the last column - setupActionsColumn(table, index); + if (index === table.columns().count() - 1) { + setupActionsColumn(table, index, actions); } }); } - function getEditRowButtonTemplate() { - return ''; - } - function editDataRow(button) { // Get the parent element of the button let row = $(button).closest('tr'); @@ -336,6 +361,75 @@ $(function () { }); } + function refreshGridAfterDelete(fieldId, tableDataSet, container) { + $.ajax({ + url: abp.appPath + 'Flex/Widgets/DataGrid/RefreshByField', + type: 'GET', + data: { + valueId: tableDataSet.valueId, + fieldId: fieldId, + modelName: fieldId, + worksheetId: tableDataSet.wsId, + worksheetInstanceId: tableDataSet.wsiId, + uiAnchor: tableDataSet.wsAnchor + }, + success: function (html) { + $('#' + fieldId).DataTable().destroy(); + $('#table-options-' + fieldId).parent().html(html); + buildDataTables($('#' + fieldId)); + abp.notify.success('Row deleted successfully.', 'Delete Row'); + }, + error: function () { + container.find('.grid-loading-overlay').remove(); + abp.notify.error('Failed to refresh the grid.', 'Delete Row'); + } + }); + } + + function deleteDataRow(button) { + let row = $(button).closest('tr'); + let rowDataSet = row[0].dataset; + let table = $(button).closest('table'); + let tableDataSet = table[0].dataset; + let fieldId = tableDataSet.fieldId; + let container = table.closest('.custom-grid-container'); + + abp.message.confirm( + 'Are you sure you want to delete this row?', + 'Delete Row', + function (confirmed) { + if (!confirmed) return; + + container.append('
Loading...
'); + + $.ajax({ + url: abp.appPath + 'Flex/Widgets/DataGrid/DeleteRow', + type: 'POST', + data: { + valueId: tableDataSet.valueId, + row: rowDataSet.rowNo, + worksheetInstanceId: tableDataSet.wsiId, + }, + success: function () { + refreshGridAfterDelete(fieldId, tableDataSet, container); + }, + error: function () { + container.find('.grid-loading-overlay').remove(); + abp.notify.error('Failed to delete the row.', 'Delete Row'); + } + }); + } + ); + } + + $(document).on('mouseenter', '.custom-dynamic-table .dropdown', function () { + let rect = this.getBoundingClientRect(); + $(this).find('.dropdown-content').css({ + top: rect.bottom + 'px', + left: rect.left + 'px' + }); + }); + PubSub.subscribe( 'worksheet_preview_datagrid_refresh', () => { diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml index 4b270ad47c..852d0e2e64 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Default.cshtml @@ -1,6 +1,5 @@ @using Microsoft.AspNetCore.Mvc.Localization @using Unity.Flex.Localization; -@using Unity.Flex.Web.Views.Shared.Components.Scoresheet; @using Volo.Abp.Authorization.Permissions; @inject IHtmlLocalizer L @inject IPermissionChecker PermissionChecker @@ -99,7 +98,8 @@ data-maxlength="@question.GetMaxLength()" data-yesvalue="@question.GetYesValue()" data-novalue="@question.GetNoValue()" - data-questiondesc="@question.Description" + data-questionlabel="@question.Label" + data-questiondesc="@question.Description" data-definition="@question.Definition" data-rows="@question.GetRowsValue()" data-required="@question.GetIsRequiredValue()"> diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js index 4143e200d7..5c90f25eb6 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/Scoresheet/Scoresheet.js @@ -1,3 +1,63 @@ +const _SANITIZE_ALLOWED_TAGS = new Set([ + 'a', 'b', 'blockquote', 'br', 'code', 'del', 'em', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', + 'li', 'ol', 'p', 'pre', 's', 'span', 'strong', 'u', 'ul' +]); +const _SANITIZE_ALLOWED_ATTRS = new Set(['href', 'rel', 'target', 'title']); +const _SANITIZE_ALLOWED_SCHEMES = new Set(['http:', 'https:', 'mailto:']); +const _SANITIZE_STRIP_WITH_CONTENT = new Set(['script', 'style', 'iframe', 'noscript', 'object', 'embed']); + +function _isSafeHref(href) { + try { + const url = new URL(href, location.href); + return _SANITIZE_ALLOWED_SCHEMES.has(url.protocol); + } catch (e) { + console.warn('sanitizeHtml: invalid href removed:', e); + return false; + } +} + +function _sanitizeElement(el) { + for (const attr of Array.from(el.attributes)) { + if (_SANITIZE_ALLOWED_ATTRS.has(attr.name)) { + if (attr.name === 'href' && !_isSafeHref(el.getAttribute('href'))) { + el.removeAttribute('href'); + } + } else { + el.removeAttribute(attr.name); + } + } +} + +function sanitizeHtml(html) { + if (!html) return ''; + const template = document.createElement('template'); + template.innerHTML = html; + // Process bottom-up so children are handled before their parent is unwrapped/removed + const elements = Array.from(template.content.querySelectorAll('*')).reverse(); + for (const el of elements) { + const tag = el.tagName.toLowerCase(); + if (_SANITIZE_STRIP_WITH_CONTENT.has(tag)) { + el.remove(); + } else if (_SANITIZE_ALLOWED_TAGS.has(tag)) { + _sanitizeElement(el); + } else { + el.replaceWith(...Array.from(el.childNodes)); + } + } + const wrapper = document.createElement('div'); + wrapper.appendChild(template.content); + return wrapper.innerHTML; +} + +function escapeHtml(text) { + return String(text) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + $(function () { function makeScoresheetsSortable() { @@ -149,7 +209,7 @@ $(function () {

@@ -163,7 +223,7 @@ $(function () {

@@ -219,7 +279,7 @@ $(function () { function buildTextAreaFieldPreview(item) { let req = item.dataset.required ? "required" : null; return ` -

${item.dataset.questiondesc}

+

${sanitizeHtml(item.dataset.questiondesc)}

-
-
-

NOTE: Selecting text will let you customize it: replace it with a variable, make it bold, italic, change the alignment, add a link, create a list, etc.

-
-
-
- -
-
-
- ${isPopulated ? `` : ``} - - ${isPopulated ? `` : ''} -
- -
-
-
- `; - } - - function initializeEditor(editorId, id, data, isPopulated, dropdownItems) { - if (tinymce.get(editorId)) { - tinymce.get(editorId).remove(); - } - - tinymce.init({ - license_key: 'gpl', - selector: `#${editorId}`, - plugins: 'lists link image preview code', - toolbar: 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview | variablesDropdownButton', - resize: true, - statusbar: true, - elementpath: false, - branding: false, - promotion: false, - content_css: false, - skin: false, - setup: function (editor) { - setupEditor(editor, id, editorId, data, isPopulated, dropdownItems); - } - }); - } + // Populate fields with empty values for new template + populateFieldsForNewTemplate(); + + // Highlight nothing in the table + $('#TemplatesTable tbody tr').removeClass('template-selected'); + + // Open right panel + openRightPanel(); + }); - function createMenuItems(dropdownItems, editor) { - return dropdownItems.map(item => ({ - type: 'menuitem', - text: item.text, - onAction: () => { - editor.insertContent(`{{${item.value}}}`); - } - })); + function proceedWithDelete(templateId, onDelete) { + // Destroy attachments table before deleting + if (emailAttachmentsTable) { + emailAttachmentsTable.destroy(); + emailAttachmentsTable = null; + } + unity.notifications.templates.template + .deleteTemplate(templateId) + .then(function () { + abp.notify.success('Template deleted successfully.'); + onDelete(); + }) + .catch(function (e) { + console.warn('Failed to delete template:', e); + abp.notify.error('Failed to delete template.'); + }); } - function fetchVariablesMenuItems(dropdownItems, editor) { - return function (callback) { - const items = createMenuItems(dropdownItems, editor); - callback(items); - }; + function handleDeleteCanCheckSuccess(response, templateId) { + if (response.canDelete) { + proceedWithDelete(templateId, function () { + PubSub.publish('reload_templates_table_with_close'); + }); + } else { + abp.notify.error(response.errorMessage || 'This template cannot be deleted because it is currently in use.'); + } } - function setupEditor(editor, id, editorId, data, isPopulated, dropdownItems) { - editor.ui.registry.addMenuButton('variablesDropdownButton', { - text: 'VARIABLES', - fetch: fetchVariablesMenuItems(dropdownItems, editor) + function handleDeleteCanCheckError(templateId) { + // If check fails, proceed with deletion + proceedWithDelete(templateId, function () { + PubSub.publish('reload_templates_table_with_close'); }); + } - editor.on('init', function () { - editor.mode.set(isPopulated ? 'readonly' : 'design'); - if (data?.bodyHTML) { - editor.setContent(data.bodyHTML); + function checkCanDeleteTemplate(templateId) { + $.ajax({ + url: `/api/form-notifications/can-delete-template/${templateId}`, + type: 'GET', + success: function (response) { + handleDeleteCanCheckSuccess(response, templateId); + }, + error: function () { + handleDeleteCanCheckError(templateId); } - editorInstances[id] = editor; }); } - function setupCardEventHandlers(cardData) { - const { id, formId, cardId, wrapperId, editorId, data, isPopulated, dropdownItems } = cardData; - - setupTemplateNameValidation(formId, wrapperId, id); - setupFormSubmission(formId, id); - setupCollapseHandlers(cardId, wrapperId); - setupEditDiscardHandlers(wrapperId, formId, editorId, id, data, dropdownItems); - setupDeleteHandler(wrapperId, id, isPopulated); + function initiateTemplateDelete(templateId) { + abp.message.confirm( + 'Are you sure you want to delete this template?', + 'Delete Template', + function (confirmed) { + if (confirmed) { + checkCanDeleteTemplate(templateId); + } + } + ); } - function setupTemplateNameValidation(formId, wrapperId, id) { - const debouncedValidation = debounce(function (templateInput, newTitle) { - checkTemplateNameUnique(newTitle, id, function (isUnique) { - toggleTemplateNameValidation(templateInput, formId, isUnique); - }); - }, 250); + UiElements.deleteButton.on('click', function () { + const templateId = $('#templateId').val(); + initiateTemplateDelete(templateId); + }); - $(`#${formId} input[name="templateName"]`).on('input', function () { - const templateInput = $(this); - const newTitle = templateInput.val().trim() || 'Untitled Template'; - $(`#${wrapperId} .template-title`).text(newTitle); + function initializeTemplateDataTables() { + // ── Table columns ──────────────────────────────────────────────────────── + const listColumns = [ + { + title: 'Id', + name: 'id', + data: 'id', + visible: false, + index: 0 + }, + { + title: 'Name', + name: 'name', + data: 'name', + index: 1, + width: '20%' + }, + { + title: 'Subject', + name: 'subject', + data: 'subject', + index: 2, + width: '60%' + }, + { + title: 'Actions', + name: 'actions', + data: 'id', + orderable: false, + searchable: false, + index: 3, + width: '130px', + render: function (data, type, row) { + return ` +
+ + +
+ `; + } + } + ]; + + const responseCallback = (result) => ( + { + recordsTotal: result.length, + recordsFiltered: result.length, + data: result + } + ); - debouncedValidation(templateInput, newTitle); + const actionButtons = [ + { + } + ]; + + const defaultVisibleColumns = ['name', 'subject', 'actions']; + const dt = $('#TemplatesTable'); + + templatesDataTable = initializeDataTable({ + dt, + defaultVisibleColumns, + listColumns, + maxRowsPerPage: 25, + defaultSortColumn: 0, + dataEndpoint: unity.notifications.templates.template.getTemplatesByTenant, + data: {}, + responseCallback, + actionButtons, + pagingEnabled: true, + reorderEnabled: false, + languageSetValues: {}, + dataTableName: 'TemplatesTable', + dynamicButtonContainerId: 'dynamicButtonContainerId', + useNullPlaceholder: true, + externalSearchId: 'search-prompts', + fixedHeaders: true }); - } - function toggleTemplateNameValidation(templateInput, formId, isUnique) { - if (!isUnique) { - templateInput.addClass("is-invalid"); - if (!$(`#${formId} .template-name-feedback`).length) { - templateInput.after(`
Template name must be unique.
`); + // ── Helper: Select and initialize template by ID ── + function selectTemplateById(templateId) { + const rows = templatesDataTable.rows().data(); + for (let i = 0; i < rows.length; i++) { + if (rows[i].id === templateId) { + const $row = $(templatesDataTable.row(i).node()); + const rowData = templatesDataTable.row(i).data(); + if (rowData) { + initializeTemplateVariables(); + initializeEditor(rowData, dropdownItems); + populateFields(rowData); + + // Highlight selected row + $('#TemplatesTable tbody tr').removeClass('template-selected'); + $row.addClass('template-selected'); + openRightPanel(); + + // Publish event for pub/sub listeners + PubSub.publish('template_selected_from_email_editor', { + templateId: templateId + }); + } + break; + } } - $(`#${formId} .saveBtn`).prop("disabled", true); - } else { - templateInput.removeClass("is-invalid"); - $(`#${formId} .template-name-feedback`).remove(); - $(`#${formId} .saveBtn`).prop("disabled", false); } - } - function setupFormSubmission(formId, id) { - $(`#${formId}`).on("submit", function (e) { - e.preventDefault(); + // ── Auto-select template from localStorage if navigating from email editor ── + let hasCheckedAutoSelect = false; + $('#TemplatesTable').on('draw.dt', function () { + if (hasCheckedAutoSelect) return; // Only check once - const formDataArray = $(this).serializeArray(); - const formData = extractFormData(formDataArray); - const editor = editorInstances[id]; - const payload = buildTemplatePayload(formData, editor); - - if (id.includes("temp")) { - saveTemplate(payload); - } else { - updateTemplate(id, payload); + const templateToSelectId = localStorage.getItem('notifications-template-to-select'); + if (!templateToSelectId) return; + + hasCheckedAutoSelect = true; + localStorage.removeItem('notifications-template-to-select'); + + // Ensure Templates tab is active + const templateTab = document.getElementById('nav-template-tab'); + if (templateTab && !templateTab.classList.contains('active')) { + const tab = new bootstrap.Tab(templateTab); + tab.show(); } + + // Wait for tab transition to complete, then find and select the template + setTimeout(() => { + selectTemplateById(templateToSelectId); + }, 150); }); - } - function setupCollapseHandlers(cardId, wrapperId) { - $(`#${cardId}`).on('show.bs.collapse', function () { - $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`) - .removeClass('fa-chevron-down') - .addClass('fa-chevron-up'); + // ── Row click → open version panel ────────────────────────────────────── + $('#TemplatesTable').on('click', 'tbody tr', function (e) { + // Don't intercept action-column clicks + if ($(e.target).closest('.dropdown, .dropdown-menu, button, a').length) return; + + const rowData = templatesDataTable.row(this).data(); + if (!rowData) return; + + // Initialize the tinymce editor with the selected template data + initializeTemplateVariables(); + initializeEditor(rowData, dropdownItems); + populateFields(rowData); + + // Highlight selected row + $('#TemplatesTable tbody tr').removeClass('template-selected'); + $(this).addClass('template-selected'); + openRightPanel(); + }); + + // ── Edit button click ────────────────────────────────────────────────── + $('#TemplatesTable').on('click', '.template-edit-btn', function (e) { + e.stopPropagation(); + const rowData = templatesDataTable.row($(this).closest('tr')).data(); + + if (!rowData) return; + + initializeTemplateVariables(); + initializeEditor(rowData, dropdownItems); + populateFields(rowData); + + // Highlight selected row + $('#TemplatesTable tbody tr').removeClass('template-selected'); + $(this).closest('tr').addClass('template-selected'); + openRightPanel(); }); - $(`#${cardId}`).on('hide.bs.collapse', function () { - $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`) - .removeClass('fa-chevron-up') - .addClass('fa-chevron-down'); + // ── Delete button click ──────────────────────────────────────────────── + $('#TemplatesTable').on('click', '.template-delete-btn', function (e) { + e.stopPropagation(); + const templateId = $(this).data('id'); + initiateTemplateDelete(templateId); }); + + // Removed nested functions - now defined at global scope above } - function setupEditDiscardHandlers(wrapperId, formId, editorId, id, data, dropdownItems) { - $(`#${wrapperId}`).on("click", ".editBtn", function () { - handleEditClick(wrapperId, editorId, id, data, dropdownItems); - }); - $(`#${wrapperId}`).on("click", ".discardBtn", function () { - handleDiscardClick(wrapperId, formId, editorId, id, data, dropdownItems); + function initializeTemplateVariables() { + $.ajax({ + url: `/api/app/template/template-variables`, + type: 'GET', + success: function (response) { + $.map(response, function (item) { + dropdownItems.push({ + text: item.name, + value: item.token + }); + }); + }, + error: function () { + // Handle error silently + } }); } - function handleEditClick(wrapperId, editorId, id, data, dropdownItems) { - const currentEditor = editorInstances[id]; - currentEditor.destroy(); + function initEmailAttachmentsTable(templateId) { + // Destroy existing table if it exists + if (emailAttachmentsTable) { + emailAttachmentsTable.destroy(); + emailAttachmentsTable = null; + } - initializeEditor(editorId, id, data, false, dropdownItems); + // Skip initialization if no template ID provided + if (!templateId) { + return; + } - const card = $(`#${wrapperId}`); - card.find(".form-input").prop('disabled', false); - card.find(".saveBtn").prop('disabled', false); - card.find(".discardBtn").removeClass("d-none"); - card.find(".editBtn").addClass("d-none"); + // Ensure table has 100% width + $('#EmailAttachmentsTable').css('width', '100%'); + + emailAttachmentsTable = $('#EmailAttachmentsTable').DataTable( + abp.libs.datatables.normalizeConfiguration({ + serverSide: false, + order: [[2, 'asc']], + searching: false, + paging: false, + select: false, + info: false, + scrollX: true, + scrollY: '200px', + scrollCollapse: true, + ajax: abp.libs.datatables.createAjax( + unity.notifications.emails.emailLogAttachment.getListByTemplateId, + function () { return templateId; }, + function (result) { return { data: result }; } + ), + columnDefs: [ + { + targets: 0, + title: 'Document Name', + data: 'fileName', + className: 'data-table-header text-break text-start', + width: '40%' + }, + { + targets: 1, + title: 'Date', + data: 'time', + className: 'data-table-header text-start', + width: '130px', + render: function (data, type) { + if (type === 'display' || type === 'filter') { + return new Date(data).toDateString(); + } + return data; + } + }, + { + targets: 2, + title: 'Attached by', + data: 'attachedBy', + className: 'data-table-header text-start', + width: '25%' + }, + { + targets: 3, + title: 'File Size', + data: 'fileSize', + className: 'data-table-header text-start', + width: '90px', + render: function (data) { + if (!data) return '—'; + const mb = data * 0.000001; + return mb >= 1 ? mb.toFixed(2) + ' MB' : (data / 1024).toFixed(0) + ' KB'; + } + }, + { + targets: 4, + title: 'Actions', + data: 'id', + width: '80px', + className: 'text-start', + orderable: false, + render: function (data) { + return generateEmailAttachmentButtonContent(data); + } + } + ], + drawCallback: function () { + if (emailAttachmentsTable) { + emailAttachmentsTable.columns.adjust(); + } + } + }) + ); } - function handleDiscardClick(wrapperId, formId, editorId, id, data, dropdownItems) { - const form = $(`#${formId}`)[0]; - form.reset(); - const currentEditor = editorInstances[id]; - currentEditor.destroy(); - initializeEditor(editorId, id, data, true, dropdownItems); + $('#email_attachment_upload_btn').on('click', function () { + $('#email_attachment_upload').click(); + }); + + $('#email_attachment_upload').on('change', function () { + uploadEmailFiles('email_attachment_upload'); + }); - $(`#${wrapperId} .form-input`).prop('disabled', true); - $(`#${wrapperId} .saveBtn`).prop('disabled', true); - $(`#${wrapperId} .discardBtn`).addClass("d-none"); - $(`#${wrapperId} .editBtn`).removeClass("d-none"); - } + function uploadEmailFiles(inputId) { + const templateId = $('#templateId').val(); + const input = document.getElementById(inputId); + if (!input?.files?.length) return; - function setupDeleteHandler(wrapperId, id, isPopulated) { - $(`#${wrapperId}`).on("click", ".deleteCardBtn", function () { - if (isPopulated) { - showDeleteConfirmation(id, wrapperId); - } else { - $(`#${wrapperId}`).remove(); + const disallowedTypes = JSON.parse(decodeURIComponent($('#Extensions').val())); + const maxFileSize = decodeURIComponent($('#EmailAttachmentMaxFileSize').val()); + + let isAllowedTypeError = false; + let isMaxFileSizeError = false; + const formData = new FormData(); + + for (let file of input.files) { + const ext = file.name.slice(file.name.lastIndexOf('.') + 1).toLowerCase(); + if (disallowedTypes.includes(ext)) { + isAllowedTypeError = true; + } + if (file.size * 0.000001 > maxFileSize) { + isMaxFileSizeError = true; + } + formData.append('files', file); + } + + if (isAllowedTypeError) { + input.value = ''; + return abp.notify.error('Error', 'File type not supported'); + } + if (isMaxFileSizeError) { + input.value = ''; + return abp.notify.error( + 'File Too Large', + 'The selected file exceeds the maximum allowed size of ' + maxFileSize + ' MB for email attachments. Please select a smaller file.' + ); + } + + const totalMaxFileSize = Number.parseFloat( + decodeURIComponent($('#TotalEmailAttachmentMaxFileSize').val()) || '25' + ); + let existingTotalBytes = 0; + if (emailAttachmentsTable) { + emailAttachmentsTable.rows().data().each(function (row) { + existingTotalBytes += (row.fileSize || 0); + }); + } + let newFilesBytes = 0; + for (let file of input.files) { + newFilesBytes += file.size; + } + const combinedMB = (existingTotalBytes + newFilesBytes) * 0.000001; + if (combinedMB > totalMaxFileSize) { + input.value = ''; + return abp.notify.error( + 'Total Size Exceeded', + 'The total size of all attachments would exceed the maximum allowed ' + totalMaxFileSize + + ' MB. Please remove existing attachments or select a smaller file.' + ); + } + + $.ajax({ + url: `/api/app/attachment/template/${templateId}/upload`, + type: 'POST', + data: formData, + processData: false, + contentType: false, + xhr: function () { + const xhr = new globalThis.XMLHttpRequest(); + xhr.upload.addEventListener('progress', function (e) { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + $('#attachment-upload-progress-bar') + .css('width', pct + '%') + .attr('value', pct) + .text(pct + '%'); + } + }); + return xhr; + }, + beforeSend: function () { + $('#email_attachment_upload_btn') + .html('Uploading...') + .prop('disabled', true); + $('#attachment-upload-progress-bar').css('width', '0%').text('0%'); + $('#attachment-upload-progress').show(); + }, + success: function () { + PubSub.publish('reload_email_attachments_table'); + }, + error: function (xhr) { + abp.notify.error(xhr.responseText || 'Failed to upload attachment.'); + }, + complete: function () { + input.value = ''; + $('#email_attachment_upload_btn') + .html('Add Attachments') + .prop('disabled', false); + $('#attachment-upload-progress').hide(); } }); } - function createCard(data = null) { - const isPopulated = data !== null; - const id = data?.id?.toString() || generateTempId(); - const cardId = `collapseDetails-${id}`; - const formId = `form-${id}`; - const wrapperId = `cardWrapper-${id}`; - const editorId = `editor-${id}`; - const type = data?.type || 'Automatic'; - const lastEdited = getCardLastEditedDate(data); - const dropdownItems = []; + // ── Draggable divider ───────────────────────────────────────────────────── + function initializeDivider() { - getTemplateVariables(dropdownItems); - - const cardConfig = { - data: data, - elementIds: { - cardId: cardId, - formId: formId, - wrapperId: wrapperId, - editorId: editorId - }, - displayInfo: { - type: type, - lastEdited: lastEdited - }, - isPopulated: isPopulated - }; + const $divider = $('#divider'); + const $leftPane = $('#leftPane'); + const $rightPane = $('#rightPane'); + const $container = $('#splitContainer'); + + let isDragging = false; + let dragStartX = 0; + let dragStartLeft = 0; + + $divider.on('mousedown', function (e) { + isDragging = true; + dragStartX = e.clientX; + dragStartLeft = $leftPane.width(); + $divider.addClass('dragging'); + $('body').addClass('split-dragging'); + e.preventDefault(); + }); - const cardHtml = generateCardHtml(cardConfig); + $(document).on('mousemove.splitDrag', function (e) { + if (!isDragging) return; - $("#cardContainer").append(cardHtml); - $(`#${wrapperId}`).data('original-name', data?.name || ''); + const totalWidth = $container.width(); + const dividerW = $divider.outerWidth(); + const delta = e.clientX - dragStartX; + let newLeft = dragStartLeft + delta; + const minLeft = totalWidth * 0.2; + const maxLeft = totalWidth * 0.8 - dividerW; - initializeEditor(editorId, id, data, isPopulated, dropdownItems); + newLeft = Math.max(minLeft, Math.min(maxLeft, newLeft)); - const cardData = { - id, formId, cardId, wrapperId, editorId, data, isPopulated, dropdownItems - }; - setupCardEventHandlers(cardData); - } + const leftPct = (newLeft / totalWidth * 100).toFixed(2); + const rightPct = ((totalWidth - newLeft - dividerW) / totalWidth * 100).toFixed(2); - function loadCardsFromService() { - $.ajax({ - url: `/api/app/template/templates-by-tenent`, - type: 'GET', - success: handleLoadCardsSuccess, - error: handleLoadCardsError + $leftPane.css('flex', `0 0 ${leftPct}%`); + $rightPane.css({ 'flex': 'none', 'width': rightPct + '%' }); + + // Recalculate table columns while dragging + if (emailAttachmentsTable) { + emailAttachmentsTable.columns.adjust(); + } + }); + + $(document).on('mouseup.splitDrag', function () { + if (isDragging) { + isDragging = false; + $divider.removeClass('dragging'); + $('body').removeClass('split-dragging'); + + // Final recalculation after dragging completes + if (emailAttachmentsTable) { + emailAttachmentsTable.columns.adjust().draw(); + } + } }); } - function handleLoadCardsSuccess(response) { - editorInstances = {}; - response.forEach(item => createCard(item)); + PubSub.subscribe('reload_email_attachments_table', () => { + reloadEmailAttachmentsTable(); + }); + + function reloadEmailAttachmentsTable() { + if (emailAttachmentsTable) { + emailAttachmentsTable.ajax.reload(); + } } - function handleLoadCardsError() { - abp.notify.error('Unable to load the templates.'); + function reloadTemplatesTable() { + if (templatesDataTable) { + templatesDataTable.ajax.reload(); + } } - function generateTempId() { - const array = new Uint32Array(1); - window.crypto.getRandomValues(array); - return `temp-${array[0].toString(36)}`; + + function reloadTemplatesTableAndClose() { + if (templatesDataTable) { + templatesDataTable.ajax.reload(); + closeRightPanel(); + } } - $("#CreateNewTemplate").on("click", function () { - createCard(); + PubSub.subscribe('reload_templates_table_no_close', () => { + reloadTemplatesTable(); + }); + + PubSub.subscribe('reload_templates_table_with_close', () => { + reloadTemplatesTableAndClose(); }); -}); \ No newline at end of file + + +}); + +function initializeEditor(data, dropdownItems) { + const templateId = 'templateBody'; + + // Remove existing editor instance if it exists + const existingEditor = tinymce.get(templateId); + if (existingEditor) { + tinymce.remove(`#${templateId}`); + } + + tinymce.init({ + license_key: 'gpl', + selector: `#${templateId}`, + plugins: 'lists link image preview code', + menubar: 'file edit view insert format tools', + toolbar: 'variablesButton | undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview', + resize: true, + statusbar: true, + elementpath: false, + branding: false, + promotion: false, + content_css: false, + skin: false, + setup: function (editor) { + setupEditor(editor, templateId, templateId, data, dropdownItems); + } + }); +} + +function setupEditor(editor, id, editorId, data, dropdownItems) { + editor.ui.registry.addMenuButton('variablesButton', { + text: 'Variables', + fetch: (callback) => { + const items = dropdownItems.map(item => ({ + type: 'menuitem', + text: item.text, + onAction: () => { + editor.insertContent(`{{${item.value}}}`); + } + })); + callback(items); + } + }); + + editor.on('init', function () { + editor.mode.set('design'); + if (data?.bodyHTML !== undefined) { + editor.setContent(data.bodyHTML); + } + }); +} + +function fetchVariablesMenuItems(dropdownItems, editor) { + return function (callback) { + const items = createMenuItems(dropdownItems, editor); + callback(items); + }; +} + +function createMenuItems(dropdownItems, editor) { + return dropdownItems.map(item => ({ + type: 'menuitem', + text: item.text, + onAction: () => { + editor.insertContent(`{{${item.value}}}`); + } + })); +} + +/** + * Generates HTML for email attachment button + * @param {string} attachmentId - Attachment ID + * @returns {string} HTML for attachment button + */ +function generateEmailAttachmentButtonContent(attachmentId) { + return ``; +} + + +/** + * Deletes email attachment with confirmation + * @param {string} attachmentId - Attachment ID to delete + */ +function deleteEmailAttachment(attachmentId) { + abp.message.confirm( + 'Are you sure you want to delete this attachment?', + 'Delete Attachment', + function (confirmed) { + if (confirmed) { + unity.notifications.emails.emailLogAttachment + .delete(attachmentId) + .then(function () { + abp.notify.success('Attachment deleted successfully.'); + PubSub.publish('reload_email_attachments_table'); + }) + .catch(function (e) { + console.warn('Failed to delete attachment:', e); + abp.notify.error('Failed to delete attachment.'); + }); + } + } + ); +} + +/** + * Validates email format + * @param {string} email - Email address to validate + * @returns {boolean} True if valid email format + */ +function validateEmail(email) { + // Optimized regex to prevent ReDoS: use atomic-like patterns with specific character classes + const emailRegex = /^[a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return emailRegex.exec(String(email).toLowerCase()) !== null; +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs index ff9a8bda3e..1e3f2b7f71 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewComponent.cs @@ -5,7 +5,7 @@ using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; using Volo.Abp.Settings; - +using Microsoft.Extensions.Configuration; namespace Unity.Notifications.Web.Views.Settings.NotificationsSettingGroup; [Widget( @@ -13,7 +13,8 @@ namespace Unity.Notifications.Web.Views.Settings.NotificationsSettingGroup; StyleTypes = [typeof(NotificationsSettingStyleBundleContributor)], AutoInitialize = true )] -public class NotificationsSettingViewComponent(ISettingProvider settingProvider) : AbpViewComponent +public class NotificationsSettingViewComponent(ISettingProvider settingProvider, + IConfiguration configuration) : AbpViewComponent { public virtual async Task InvokeAsync() { @@ -21,12 +22,22 @@ public virtual async Task InvokeAsync() var success = int.TryParse(retryMaxSetting, out int maximumRetryAttempts); if (!success) { maximumRetryAttempts = 3; } + + var model = new NotificationsSettingViewModel { DefaultFromAddress = await settingProvider.GetOrNullAsync(Notifications.Settings.NotificationsSettings.Mailing.DefaultFromAddress) ?? "", - MaximumRetryAttempts = maximumRetryAttempts + MaximumRetryAttempts = maximumRetryAttempts, + EnableEmailDelay = string.Equals( + await settingProvider.GetOrNullAsync(Notifications.Settings.NotificationsSettings.Mailing.EnableEmailDelay), + "true", System.StringComparison.OrdinalIgnoreCase), + Extensions = configuration["S3:DisallowedFileTypes"] ?? "", + MaxFileSize = configuration["S3:MaxFileSize"] ?? "", + EmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentMaxFileSize"] ?? "", + TotalEmailAttachmentMaxFileSize = configuration["S3:EmailAttachmentsTotalMaxFileSize"] ?? "25" }; + return View("~/Views/Settings/NotificationsSettingGroup/Default.cshtml", model); } @@ -40,6 +51,9 @@ public override void ConfigureBundle(BundleConfigurationContext context) context .Files .AddIfNotContains("/Views/Settings/NotificationsSettingGroup/InternalEmailGroups.js"); + context + .Files + .AddIfNotContains("/libs/select2/dist/js/select2.full.min.js"); } } @@ -49,6 +63,10 @@ public override void ConfigureBundle(BundleConfigurationContext context) { context.Files .AddIfNotContains("/Views/Settings/NotificationsSettingGroup/Default.css"); + context.Files + .AddIfNotContains("/libs/select2/dist/css/select2.min.css"); + context.Files + .AddIfNotContains("/libs/select2-bootstrap-5-theme/dist/select2-bootstrap-5-theme.min.css"); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs index 1e4d39d8d1..e9497f02ab 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/NotificationsSettingViewModel.cs @@ -14,4 +14,12 @@ public class NotificationsSettingViewModel [MaxValue(10)] [MaxLength(2)] public int MaximumRetryAttempts { get; set; } = 3; + + [Display(Name = "Enable Schedule Email for Individual Application")] + public bool EnableEmailDelay { get; set; } + + public string Extensions { get; set; } = string.Empty; + public string MaxFileSize { get; set; } = string.Empty; + public string EmailAttachmentMaxFileSize { get; set; } = string.Empty; + public string TotalEmailAttachmentMaxFileSize { get; set; } = string.Empty; } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml new file mode 100644 index 0000000000..e09b8ab96b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_TemplateDetails.cshtml @@ -0,0 +1,75 @@ +
+
+ + + + +
+
+ +
+ +
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ + +
+
+
+
+ NOTE: Selecting text will let you customize it: replace it with a + variable, make it bold, italic, change the alignment, add a link, create a list, etc. +
+
\ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_Templates.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_Templates.cshtml new file mode 100644 index 0000000000..7a80cf2c56 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/_Templates.cshtml @@ -0,0 +1,33 @@ +
+ + + + +
+ + +
+
+
+ +
+
+
+ + +
+ + + +
+
\ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj index 2e9f3f992b..1c140ce8e0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/Unity.Notifications.EntityFrameworkCore.Tests.csproj @@ -17,6 +17,8 @@ + +
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs index 9d0bd15462..63d6e4e348 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Enums/PaymentRequestStatus.cs @@ -19,5 +19,6 @@ public enum PaymentRequestStatus Failed = 11, FSB = 12, // Financial Services Branch - Prevent CAS Payment HistoricalPayment = 13, + Cancelled = 14, } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs index bd604dbc3c..3040e3e8ba 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs @@ -10,7 +10,7 @@ public interface IPaymentRequestAppService : IApplicationService { Task> CreateAsync(List paymentRequests); Task> CreateHistoricalAsync(List paymentRequests); - Task> GetListAsync(PagedAndSortedResultRequestDto input); + Task> GetListAsync(PaymentRequestListInputDto input); Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId); Task> GetListByApplicationIdAsync(Guid applicationId); Task> GetListByPaymentIdsAsync(List paymentIds); @@ -25,5 +25,6 @@ public interface IPaymentRequestAppService : IApplicationService Task> GetPaymentPendingListByCorrelationIdsAsync(IEnumerable correlationIds); Task GetApplicationPaymentRollupAsync(Guid applicationId); Task> GetApplicationPaymentRollupBatchAsync(List applicationIds); + Task CancelAsync(Guid paymentRequestId); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs index 521f5c2523..36c55bc26b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs @@ -48,8 +48,14 @@ public class PaymentRequestDto : AuditedEntityDto, IMultiTenant public string? FsbApNotified { get; set; } public Guid? ApplicantId { get; set; } + public string? Category { get; set; } public Guid? TenantId { get; set; } + // Cancellation tracking + public DateTime? CancelledOn { get; set; } + public Guid? CancelledById { get; set; } + public string? CancelledBy { get; set; } + public static explicit operator PaymentRequestDto(CreatePaymentRequestDto v) { throw new NotImplementedException(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestListInputDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestListInputDto.cs new file mode 100644 index 0000000000..ede13c1d91 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestListInputDto.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using Volo.Abp.Application.Dtos; + +namespace Unity.Payments.PaymentRequests +{ + public class PaymentRequestListInputDto : PagedAndSortedResultRequestDto + { + public IReadOnlyList? RequestedFields { get; set; } + } +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs index 8a2f4d4314..4742e2178b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs @@ -64,6 +64,11 @@ public class PaymentRequest : FullAuditedAggregateRoot, IMultiTenant, ICor public virtual DateTime? FsbNotificationSentDate { get; private set; } public virtual string? FsbApNotified { get; private set; } + // Cancellation tracking + public virtual DateTime? CancelledOn { get; private set; } + public virtual Guid? CancelledById { get; private set; } + public virtual string? CancelledBy { get; private set; } + protected PaymentRequest() { ExpenseApprovals = []; @@ -222,6 +227,14 @@ public PaymentRequest ClearFsbNotificationEmailLog() return this; } + public PaymentRequest SetCancellation(DateTime cancelledOn, Guid cancelledById, string cancelledBy) + { + CancelledOn = cancelledOn; + CancelledById = cancelledById; + CancelledBy = cancelledBy; + return this; + } + public PaymentRequest ValidatePaymentRequest() { if (Amount <= 0) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs index b2acd68f79..f9c74fb61c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs @@ -13,7 +13,7 @@ public interface IPaymentRequestQueryManager Task GetPaymentRequestCountAsync(); Task GetPaymentRequestByIdAsync(Guid paymentRequestId); Task> GetPaymentRequestsByIdsAsync(List paymentRequestIds, bool includeDetails = false); - Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting); + Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting, IReadOnlyList? requestedFields = null); Task> GetListByApplicationIdAsync(Guid applicationId); Task> GetListByApplicationIdsAsync(List applicationIds); Task> GetListByPaymentIdsAsync(List paymentIds); @@ -24,7 +24,7 @@ public interface IPaymentRequestQueryManager // DTO Creation & Mapping Task CreatePaymentRequestDtoAsync(Guid paymentRequestId); - Task> MapToDtoAndLoadDetailsAsync(List paymentsList); + Task> MapToDtoAndLoadDetailsAsync(List paymentsList, IReadOnlyList? requestedFields = null); Task GetAccountDistributionCodeAsync(AccountCodingDto? accountCoding); // Queue Operations diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs index bccb0910c3..a69beb5b69 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentsManager.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Unity.Payments.Domain.PaymentRequests; using Unity.Payments.Domain.Shared; namespace Unity.Payments.Domain.Services @@ -7,7 +8,8 @@ namespace Unity.Payments.Domain.Services public interface IPaymentsManager { Task UpdatePaymentStatusAsync(Guid paymentRequestId, PaymentApprovalAction triggerAction); + Task CancelPaymentAsync(Guid paymentRequestId); Task GetFormPreventPaymentStatusByPaymentRequestId(Guid paymentRequestId); - Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId); + Task GetFormPreventPaymentStatusByApplicationId(Guid applicationId); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs index 8711b61f06..9925edeff7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs @@ -23,6 +23,43 @@ public class PaymentRequestQueryManager( IObjectMapper objectMapper, IApplicationRepository applicationRepository) : DomainService, IPaymentRequestQueryManager { + private static readonly HashSet SiteFields = new(StringComparer.OrdinalIgnoreCase) + { + "siteNumber", + "payGroup" + }; + + private static readonly HashSet AccountCodingFields = new(StringComparer.OrdinalIgnoreCase) + { + "accountCodingDisplay" + }; + + private static readonly HashSet TagFields = new(StringComparer.OrdinalIgnoreCase) + { + "paymentTags" + }; + + private static readonly HashSet RequesterFields = new(StringComparer.OrdinalIgnoreCase) + { + "paymentRequesterName" + }; + + private static readonly HashSet ExpenseApprovalFields = new(StringComparer.OrdinalIgnoreCase) + { + "l1ApproverName", + "l1ApprovalDate", + "l2ApproverName", + "l2ApprovalDate", + "l3ApproverName", + "l3ApprovalDate" + }; + + private static readonly HashSet ApplicantFields = new(StringComparer.OrdinalIgnoreCase) + { + "applicantName", + "category" + }; + public Task GetPaymentRequestCountBySiteIdAsync(Guid siteId) { return paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId); @@ -43,17 +80,40 @@ public async Task> GetPaymentRequestsByIdsAsync(List return await paymentRequestRepository.GetListAsync(x => paymentRequestIds.Contains(x.Id), includeDetails: includeDetails); } - public async Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting) + public async Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting, IReadOnlyList? requestedFields = null) { - await paymentRequestRepository.GetPagedListAsync(skipCount, maxResultCount, sorting, includeDetails: true); - var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync(); + var includeSite = IncludesAny(requestedFields, SiteFields); + var includeAccountCoding = IncludesAny(requestedFields, AccountCodingFields); + var includeTags = IncludesAny(requestedFields, TagFields); + var includeExpenseApprovals = IncludesAny(requestedFields, ExpenseApprovalFields); + + paymentsQueryable = paymentsQueryable.AsNoTracking(); + + if (includeSite) + { + paymentsQueryable = paymentsQueryable.Include(pr => pr.Site); + } + + if (includeAccountCoding) + { + paymentsQueryable = paymentsQueryable.Include(pr => pr.AccountCoding); + } + + if (includeTags) + { + paymentsQueryable = paymentsQueryable + .Include(pr => pr.PaymentTags) + .ThenInclude(pt => pt.Tag); + } + + if (includeExpenseApprovals) + { + paymentsQueryable = paymentsQueryable.Include(pr => pr.ExpenseApprovals); + } + #pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. - var paymentWithIncludes = await paymentsQueryable - .Include(pr => pr.AccountCoding) - .Include(pr => pr.PaymentTags) - .ThenInclude(pt => pt.Tag) - .ToListAsync(); + var paymentWithIncludes = await paymentsQueryable.ToListAsync(); #pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types. return paymentWithIncludes; @@ -126,11 +186,29 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid return await paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId); } - public async Task> MapToDtoAndLoadDetailsAsync(List paymentsList) + public async Task> MapToDtoAndLoadDetailsAsync(List paymentsList, IReadOnlyList? requestedFields = null) { + var includeApplicant = IncludesAny(requestedFields, ApplicantFields); + var includeRequester = IncludesAny(requestedFields, RequesterFields); + var includeExpenseApprovals = IncludesAny(requestedFields, ExpenseApprovalFields); + var includeAccountCoding = IncludesAny(requestedFields, AccountCodingFields); + var paymentDtos = objectMapper.Map, List>(paymentsList); - // Batch-fetch applicant IDs for all unique correlation IDs (application IDs) + if (includeApplicant) + { + await LoadApplicantDetailsAsync(paymentDtos); + } + + var userDictionary = await BuildUserDictionaryAsync(paymentDtos, includeRequester, includeExpenseApprovals); + + await ApplyUserDetailsToPaymentsAsync(paymentDtos, userDictionary, includeRequester, includeAccountCoding, includeExpenseApprovals); + + return paymentDtos; + } + + private async Task LoadApplicantDetailsAsync(List paymentDtos) + { var applicationIds = paymentDtos .Select(p => p.CorrelationId) .Distinct() @@ -138,6 +216,7 @@ public async Task> MapToDtoAndLoadDetailsAsync(List a.Id, a => a.ApplicantId); + var categoryByApplicationId = applications.ToDictionary(a => a.Id, a => a.ApplicationForm?.Category); foreach (var paymentDto in paymentDtos) { @@ -145,23 +224,41 @@ public async Task> MapToDtoAndLoadDetailsAsync(List paymentRequesterIds = [.. paymentDtos - .Select(payment => payment.CreatorId) - .OfType() - .Distinct()]; + private async Task> BuildUserDictionaryAsync( + List paymentDtos, + bool includeRequester, + bool includeExpenseApprovals) + { + var userDictionary = new Dictionary(); - List expenseApprovalCreatorIds = [.. paymentDtos - .SelectMany(payment => payment.ExpenseApprovals) - .Where(expenseApproval => expenseApproval.Status != ExpenseApprovalStatus.Requested) - .Select(expenseApproval => expenseApproval.DecisionUserId) - .OfType() - .Distinct()]; + if (!includeRequester && !includeExpenseApprovals) + { + return userDictionary; + } + + var paymentRequesterIds = includeRequester + ? paymentDtos + .Select(payment => payment.CreatorId) + .OfType() + .Distinct() + : []; + + var expenseApprovalCreatorIds = includeExpenseApprovals + ? paymentDtos + .SelectMany(payment => payment.ExpenseApprovals ?? []) + .Where(expenseApproval => expenseApproval.Status != ExpenseApprovalStatus.Requested) + .Select(expenseApproval => expenseApproval.DecisionUserId) + .OfType() + .Distinct() + : []; - // Call external lookup for each distinct User Id and store in a dictionary. - var userDictionary = new Dictionary(); var allUserIds = paymentRequesterIds.Concat(expenseApprovalCreatorIds).Distinct(); foreach (var userId in allUserIds) { @@ -172,34 +269,59 @@ public async Task> MapToDtoAndLoadDetailsAsync(List paymentDtos, + Dictionary userDictionary, + bool includeRequester, + bool includeAccountCoding, + bool includeExpenseApprovals) + { foreach (var paymentRequestDto in paymentDtos) { - if (paymentRequestDto.CreatorId.HasValue + if (includeRequester + && paymentRequestDto.CreatorId.HasValue && userDictionary.TryGetValue(paymentRequestDto.CreatorId.Value, out var paymentRequestUserDto)) { paymentRequestDto.CreatorUser = paymentRequestUserDto; } - if (paymentRequestDto.AccountCoding != null) + if (includeAccountCoding && paymentRequestDto.AccountCoding != null) { paymentRequestDto.AccountCodingDisplay = await GetAccountDistributionCodeAsync(paymentRequestDto.AccountCoding); } - if (paymentRequestDto.ExpenseApprovals != null) + if (includeExpenseApprovals && paymentRequestDto.ExpenseApprovals != null) { - foreach (var expenseApproval in paymentRequestDto.ExpenseApprovals) - { - if (expenseApproval.DecisionUserId.HasValue - && userDictionary.TryGetValue(expenseApproval.DecisionUserId.Value, out var expenseApprovalUserDto)) - { - expenseApproval.DecisionUser = expenseApprovalUserDto; - } - } + ApplyExpenseApprovalUsers(paymentRequestDto.ExpenseApprovals, userDictionary); } } + } - return paymentDtos; + private static void ApplyExpenseApprovalUsers( + IEnumerable expenseApprovals, + Dictionary userDictionary) + { + foreach (var expenseApproval in expenseApprovals) + { + if (expenseApproval.DecisionUserId.HasValue + && userDictionary.TryGetValue(expenseApproval.DecisionUserId.Value, out var expenseApprovalUserDto)) + { + expenseApproval.DecisionUser = expenseApprovalUserDto; + } + } + } + + private static bool IncludesAny(IReadOnlyList? requestedFields, IReadOnlySet fieldsToCheck) + { + if (requestedFields == null || requestedFields.Count == 0) + { + return true; + } + + return requestedFields.Any(fieldsToCheck.Contains); } public Task GetAccountDistributionCodeAsync(AccountCodingDto? accountCoding) diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs index b7413f3c9d..ddefff28db 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs @@ -49,10 +49,20 @@ private void ConfigureWorkflow(StateMachine HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()) - .PermitIf(PaymentApprovalAction.L3Decline, PaymentRequestStatus.L3Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()); + .PermitIf(PaymentApprovalAction.L3Decline, PaymentRequestStatus.L3Declined, () => HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline).GetAwaiter().GetResult()) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); paymentStateMachine.Configure(PaymentRequestStatus.L2Declined) .PermitIf(PaymentApprovalAction.Submit, PaymentRequestStatus.Submitted, () => HasPermissionAsync(PaymentsPermissions.Payments.L2ApproveOrDecline).GetAwaiter().GetResult()); + + paymentStateMachine.Configure(PaymentRequestStatus.L1Pending) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + + paymentStateMachine.Configure(PaymentRequestStatus.L2Pending) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); + + paymentStateMachine.Configure(PaymentRequestStatus.HistoricalPayment) + .PermitIf(PaymentApprovalAction.Cancel, PaymentRequestStatus.Cancelled, () => HasPermissionAsync(PaymentsPermissions.Payments.CancelPayment).GetAwaiter().GetResult()); } private async Task HasPermissionAsync(string permission) @@ -194,5 +204,32 @@ public async Task UpdatePaymentStatusAsync(Guid paymentRequestId, PaymentApprova await uow.SaveChangesAsync(); } + + [Volo.Abp.Uow.UnitOfWork] + public virtual async Task CancelPaymentAsync(Guid paymentRequestId) + { + var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId, true); + var isHistoricalPayment = paymentRequest.Status == PaymentRequestStatus.HistoricalPayment; + var statusChange = paymentRequest.Status; + + var workflow = new PaymentsWorkflow( + () => statusChange, s => statusChange = s, ConfigureWorkflow); + + await workflow.ExecuteActionAsync(PaymentApprovalAction.Cancel); + + paymentRequest.SetPaymentRequestStatus(PaymentRequestStatus.Cancelled); + paymentRequest.SetCancellation( + Clock.Now, + currentUser.GetId(), + $"{currentUser.Name} {currentUser.SurName}".Trim()); + + if (isHistoricalPayment) + { + paymentRequest.SetInvoiceStatus(CasPaymentRequestStatus.Cancelled); + paymentRequest.SetPaymentStatus(CasPaymentRequestStatus.NotPaid); + } + + return await paymentRequestRepository.UpdateAsync(paymentRequest); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs index c449f1402e..26ad4fc611 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Shared/PaymentsAction.cs @@ -24,5 +24,6 @@ public enum PaymentApprovalAction L2Decline, L3Approve, L3Decline, - Submit + Submit, + Cancel } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs index f476876bd9..2594d6aef9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs @@ -42,12 +42,24 @@ public static void ConfigurePayments( .OnDelete(DeleteBehavior.NoAction); b.HasIndex(e => e.ReferenceNumber).IsUnique(); + b.HasIndex(e => e.CreationTime); + b.HasIndex(e => e.Status); + b.HasIndex(e => e.CorrelationId); + b.HasIndex(e => e.SiteId); + b.HasIndex(e => e.AccountCodingId); + b.HasIndex(e => new { e.TenantId, e.CreationTime }) + .HasFilter("\"IsDeleted\" = false"); // FSB Notification Tracking b.Property(x => x.FsbNotificationEmailLogId).IsRequired(false); b.Property(x => x.FsbNotificationSentDate).IsRequired(false); b.Property(x => x.FsbApNotified).IsRequired(false).HasMaxLength(10); b.HasIndex(x => x.FsbNotificationEmailLogId); + + // Cancellation tracking + b.Property(x => x.CancelledOn).HasColumnName("CancelledOn").IsRequired(false); + b.Property(x => x.CancelledById).HasColumnName("CancelledById").IsRequired(false); + b.Property(x => x.CancelledBy).HasColumnName("CancelledBy").HasMaxLength(256).IsRequired(false); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs index 5d5212de66..49292689f5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsQueryableExtensions.cs @@ -11,7 +11,7 @@ public static IQueryable IncludeDetails(this IQueryable s.Site) - .ThenInclude(site => site.Supplier) + .ThenInclude(site => site!.Supplier) .Include(p => p.AccountCoding) .Include(y => y.ExpenseApprovals); } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs index c46f71e5a3..4910b5e003 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs @@ -58,6 +58,7 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid .Where(p => p.Status != PaymentRequestStatus.L1Declined && p.Status != PaymentRequestStatus.L2Declined && p.Status != PaymentRequestStatus.L3Declined + && p.Status != PaymentRequestStatus.Cancelled && p.InvoiceStatus != CasPaymentRequestStatus.Cancelled && p.InvoiceStatus != CasPaymentRequestStatus.NotFound && p.InvoiceStatus != CasPaymentRequestStatus.ErrorFromCas) @@ -103,10 +104,10 @@ public async Task> GetPaymentPendingListByCorrelationIdAsyn public async Task> GetPaymentPendingListByCorrelationIdsAsync(IEnumerable correlationIds) { - var idList = correlationIds?.ToList() ?? new List(); + var idList = correlationIds?.ToList() ?? []; if (idList.Count == 0) { - return new List(); + return []; } var dbSet = await GetDbSetAsync(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs index fd46067031..df3bde3fee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs @@ -143,7 +143,10 @@ private static PaymentRequestDto MapToPaymentRequestDto(PaymentRequest result) CreationTime = result.CreationTime, Status = result.Status, ReferenceNumber = result.ReferenceNumber, - SubmissionConfirmationCode = result.SubmissionConfirmationCode + SubmissionConfirmationCode = result.SubmissionConfirmationCode, + CancelledOn = result.CancelledOn, + CancelledById = result.CancelledById, + CancelledBy = result.CancelledBy }; } @@ -325,18 +328,28 @@ public async Task> GetListByApplicationIdsAsync(List> GetListAsync(PagedAndSortedResultRequestDto input) - { - var totalCount = await paymentRequestQueryManager.GetPaymentRequestCountAsync(); + public async Task> GetListAsync(PaymentRequestListInputDto input) + { using (dataFilter.Disable()) { - var paymentWithIncludes = await paymentRequestQueryManager.GetPagedPaymentRequestsWithIncludesAsync(input.SkipCount, input.MaxResultCount, input.Sorting ?? string.Empty); + var paymentWithIncludes = await paymentRequestQueryManager.GetPagedPaymentRequestsWithIncludesAsync( + input.SkipCount, + input.MaxResultCount, + input.Sorting ?? string.Empty, + input.RequestedFields); - var mappedPayments = await paymentRequestQueryManager.MapToDtoAndLoadDetailsAsync(paymentWithIncludes); + var mappedPayments = await paymentRequestQueryManager.MapToDtoAndLoadDetailsAsync( + paymentWithIncludes, + input.RequestedFields); paymentRequestQueryManager.ApplyErrorSummary(mappedPayments); - return new PagedResultDto(totalCount, mappedPayments); +#pragma warning disable S125 + //While the DataTable is client side, server side count query is not necessary. + //var totalCount = await paymentRequestQueryManager.GetPaymentRequestCountAsync(); +#pragma warning restore S125 + return new PagedResultDto(paymentWithIncludes.Count, mappedPayments); + } } @@ -415,5 +428,28 @@ public async Task> GetApplicationP var childApplicationIdsByParent = await applicationLinksService.Value.GetChildApplicationIdsByParentIdsAsync(applicationIds); return await paymentRequestQueryManager.GetApplicationPaymentRollupBatchAsync(applicationIds, childApplicationIdsByParent); } + + [Authorize(PaymentsPermissions.Payments.CancelPayment)] + public virtual async Task CancelAsync(Guid paymentRequestId) + { + var payment = await paymentRequestQueryManager.GetPaymentRequestByIdAsync(paymentRequestId) + ?? throw new BusinessException("Payments:PaymentRequestNotFound") + .WithData("Id", paymentRequestId); + + PaymentRequestStatus[] eligibleStatuses = + [ + PaymentRequestStatus.HistoricalPayment, + PaymentRequestStatus.L1Pending, + PaymentRequestStatus.L2Pending, + PaymentRequestStatus.L3Pending + ]; + + if (!eligibleStatuses.Contains(payment.Status)) + throw new BusinessException("Payments:CancellationNotAllowed") + .WithData("Status", payment.Status.ToString()); + + var result = await paymentsManager.CancelPaymentAsync(paymentRequestId); + return MapToPaymentRequestDto(result); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs index e4186e3b76..9dd29990d0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Permissions/PaymentsPermissionDefinitionProvider.cs @@ -20,6 +20,7 @@ public override void Define(IPermissionDefinitionContext context) paymentsPermissions.AddChild(PaymentsPermissions.Payments.RequestPayment, L("Permission:Payments.RequestPayment")); paymentsPermissions.AddChild(PaymentsPermissions.Payments.AccountCodingOverride, L("Permission:Payments.AccountCodingOverride")); paymentsPermissions.AddChild(PaymentsPermissions.Payments.AddHistoricalPayment, L("Permission:Payments.AddHistoricalPayment")); + paymentsPermissions.AddChild(PaymentsPermissions.Payments.CancelPayment, L("Permission:Payments.CancelPayment")); //-- PAYMENT INFO PERMISSIONS grantApplicationPermissionsGroup.Add_PaymentInfo_Permissions(); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json index ab89d7c0da..ff25b18a72 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Localization/Payments/en.json @@ -67,6 +67,7 @@ "ApplicationPaymentListTable:PaymentStatus": "CAS Payment Status", "ApplicationPaymentListTable:Note": "Note", "ApplicationPaymentListTable:FsbApNotified": "FSB-AP Notified", + "ApplicationPaymentListTable:Category": "Category", "ApplicantInfoView:SupplierInfoTitle": "Supplier Info", "ApplicantInfoView:ApplicantInfo:SupplierNumber": "Supplier #", @@ -129,6 +130,8 @@ "Permission:Payments.AccountCodingOverride": "Override Account Coding", "Permission:Payments.EditFormPaymentConfiguration": "Edit Form Payment Configuration", "Permission:Payments.AddHistoricalPayment": "Add Historical Payment", + "Permission:Payments.CancelPayment": "Cancel Payment", + "Enum:PaymentRequestStatus.Cancelled": "Cancelled", "Enum:PaymentRequestStatus.L1Pending": "L1 Pending", "Enum:PaymentRequestStatus.L1Approved": "L1 Approved", "Enum:PaymentRequestStatus.L1Declined": "L1 Declined", diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs index a0b470a799..efd2fb0af4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Shared/Permissions/PaymentsPermissions.cs @@ -18,6 +18,7 @@ public static class Payments public const string EditSupplierInfo = Default + ".EditSupplierInfo"; public const string EditFormPaymentConfiguration = Default + ".EditFormPaymentConfiguration"; public const string AddHistoricalPayment = Default + ".AddHistoricalPayment"; + public const string CancelPayment = Default + ".CancelPayment"; } public static string[] GetAll() diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css index 267a66e061..814119e4a9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.css @@ -51,3 +51,36 @@ background-color: #FEEAEA; color: #FF0909 !important; } + +/* DataTables stateRestore - Save View */ +.container-fluid .dtsr-background { + background: rgba(0, 0, 0, 0.5) !important; +} + +.cstm-save-view .dt-button-collection { + width: 230px; +} + +.cstm-save-view .dt-button-split a { + line-height: 35px; +} + +.container-fluid .dtsr-creation-title, +.container-fluid .dtsr-confirmation-title { + font-weight: 700; + font-size: 1.25rem; +} + +.container-fluid .dtsr-creation input, +.container-fluid .dtsr-rename-modal input { + font-size: var(--bc-font-size); + color: var(--bc-colors-grey-text-500); + border-radius: 4px !important; + border: 2px solid var(--bc-colors-blue-primary); +} + +.container-fluid div.dtsr-confirmation button, +.container-fluid div.dtsr-creation button { + color: var(--bc-colors-white-primary-500); + background-color: var(--bc-colors-blue-primary); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js index 7b858aa3cc..ab6f6489c7 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js @@ -1,6 +1,7 @@ $(function () { const l = abp.localization.getResource('Payments'); const nullPlaceholder = '—'; + const requestedFieldsStorageKey = 'PaymentRequests_RequestedFields'; const formatter = createNumberFormatter(); const guidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; let dt = $('#PaymentRequestListTable'); @@ -9,11 +10,11 @@ $(function () { const listColumns = getColumns(); const defaultVisibleColumns = [ + 'select', 'referenceNumber', 'batchName', 'applicantName', 'supplierNumber', - 'creationTime', 'siteNumber', 'contactNumber', 'invoiceNumber', @@ -29,6 +30,30 @@ $(function () { 'CASResponse', 'accountCodingDisplay' ]; + let initialLoad = true; + let isRestoringState = false; + let refreshDataTimeout = null; + + let languageSetValues = { + buttons: { + stateRestore: 'View %d' + }, + stateRestore: { + creationModal: { + title: 'Create View', + name: 'Name', + button: 'Save', + }, + emptyStates: 'No saved views', + renameTitle: 'Rename View', + renameLabel: 'New name for "%s"', + removeTitle: 'Delete View', + removeConfirm: 'Are you sure you want to delete "%s"?', + removeSubmit: 'Delete', + duplicateError: 'A view with this name already exists.', + removeError: 'Failed to remove view.', + } + }; let paymentRequestStatusModal = new abp.ModalManager({ viewUrl: 'PaymentApprovals/UpdatePaymentRequestStatus', @@ -65,6 +90,7 @@ $(function () { payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }) @@ -74,6 +100,9 @@ $(function () { { text: 'Approve', className: 'custom-table-btn flex-none btn btn-secondary payment-status', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { // Store payment IDs in distributed cache to avoid URL length limits unity.payments.paymentRequests.paymentBulkActions @@ -94,6 +123,9 @@ $(function () { { text: 'Decline', className: 'custom-table-btn flex-none btn btn-secondary payment-status', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { // Store payment IDs in distributed cache to avoid URL length limits unity.payments.paymentRequests.paymentBulkActions @@ -111,9 +143,44 @@ $(function () { }); } }, + ...(abp.auth.isGranted('PaymentsPermissions.Payments.CancelPayment') ? [{ + text: 'Cancel', + className: 'custom-table-btn flex-none btn btn-secondary payment-cancel', + action: function (e, dt, node, config) { + if (selectedPaymentIds?.length !== 1) return; + const rowData = dt.rows({ selected: true }).data().toArray()[0]; + abp.message.confirm( + `Are you sure you want to cancel the payment: "${rowData.referenceNumber}"?`, + 'Cancel Payment', + function (confirmed) { + if (!confirmed) return; + unity.payments.paymentRequests.paymentRequest + .cancel(selectedPaymentIds[0]) + .then(function () { + abp.notify.success('Payment has been cancelled successfully.'); + $(".select-all-payments").prop("checked", false); + payment_approve_buttons.disable(); + payment_check_status_buttons.disable(); + history_button.disable(); + if (cancel_button) cancel_button.disable(); + selectedPaymentIds = []; + PubSub.publish("deselect_batchpayment_application", "reset_data"); + dataTable.ajax.reload(null, false); + }) + .catch(function (err) { + abp.notify.error('Failed to cancel payment. Please try again.'); + console.warn('Cancel payment error:', err); + }); + } + ); + } + }] : []), { text: 'History', className: 'custom-table-btn flex-none btn btn-secondary history', + attr: { + 'data-selector': 'batch-payment-table-actions' + }, action: function (e, dt, node, config) { location.href = '/PaymentHistory/Details?PaymentId=' + selectedPaymentIds[0]; } @@ -127,6 +194,66 @@ $(function () { id: 'btn-toggle-filter' } }, + { + extend: 'savedStates', + className: 'custom-table-btn flex-none btn btn-secondary grp-savedStates', + config: { + creationModal: true, + splitSecondaries: [ + { extend: 'updateState', text: ' Update' }, + { extend: 'renameState', text: ' Rename' }, + { extend: 'removeState', text: ' Delete' } + ] + }, + buttons: [ + { extend: 'createState', text: 'Save As View' }, + { + text: 'Reset to Default View', + action: function (e, dt, node, config) { + let dtInit = dt.init(); + let initialSortOrder = dtInit?.order ?? []; + + dt.columns().visible(false); + + const allColumnNames = dt.settings()[0].aoColumns + .map(col => col.name) + .filter(colName => !defaultVisibleColumns.includes(colName)); + + const orderedIndexes = []; + defaultVisibleColumns.forEach((colName) => { + const colIdx = dt.column(`${colName}:name`).index(); + if (colIdx !== undefined && colIdx !== -1) { + dt.column(colIdx).visible(true); + orderedIndexes.push(colIdx); + } + }); + + allColumnNames.forEach((colName) => { + const colIdx = dt.column(`${colName}:name`).index(); + if (colIdx !== undefined && colIdx !== -1) { + orderedIndexes.push(colIdx); + } + }); + + dt.colReorder.order(orderedIndexes); + dt.columns.adjust(); + + if (typeof dt.filterRow === 'function') { + const filterRowApi = dt.filterRow(); + if (filterRowApi && typeof filterRowApi?.clearFilters === 'function') { + filterRowApi.clearFilters(); + } + } + + $('.dt-search input').val(''); + $('#search').val(''); + dt.search('').order(initialSortOrder).draw(); + } + }, + { extend: 'removeAllStates', text: 'Delete All Views' }, + { extend: 'spacer', style: 'bar' } + ] + }, { extend: 'csv', text: 'Export', @@ -172,16 +299,98 @@ $(function () { dir: 'desc' }, dataEndpoint: unity.payments.paymentRequests.paymentRequest.getList, - data: {}, + data: function () { + let requestedFields; + if (dataTable) { + try { + const cols = dataTable.settings()[0].aoColumns; + requestedFields = cols + .filter(function (col, idx) { return dataTable.column(idx).visible(); }) + .map(function (col) { return col.sName; }) + .filter(function (name) { return !!name; }); + if (requestedFields.length > 0) { + localStorage.setItem(requestedFieldsStorageKey, JSON.stringify(requestedFields)); + } + } catch { + // DataTable may still be initializing. + } + } + + if (!requestedFields || requestedFields.length === 0) { + try { + const saved = localStorage.getItem(requestedFieldsStorageKey); + if (saved) { + requestedFields = JSON.parse(saved); + } + } catch { + // Ignore local storage parse errors and use defaults. + } + } + + if (!requestedFields || requestedFields.length === 0) { + requestedFields = defaultVisibleColumns; + } + + return { + requestedFields: requestedFields + }; + }, responseCallback, actionButtons, + deferRender: true, pagingEnabled: true, reorderEnabled: true, - languageSetValues: {}, + languageSetValues, dataTableName: 'PaymentRequestListTable', dynamicButtonContainerId: 'dynamicButtonContainerId', useNullPlaceholder: true, - fixedHeaders: true + fixedHeaders: true, + onStateSaveParams: function (settings, data) { + data.customFilters = { + externalSearchValue: $('#search').val() || '' + }; + }, + onStateLoadParams: function (settings, data) { + if (!initialLoad) { + isRestoringState = true; + if (data?.customFilters) { + $('#search').val(data.customFilters.externalSearchValue || ''); + } + } + }, + onStateLoaded: function (dtApi, data) { + if (!initialLoad) { + isRestoringState = false; + dtApi.ajax.reload(null, false); + } + initialLoad = false; + }, + enableContextMenu: true, + contextMenuActionsSelector: '[data-selector="batch-payment-table-actions"]' + }); + + $('.grp-savedStates').text('Save View'); + $('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view'); + + dataTable.on('column-visibility.dt', function (e, settings, columnIdx) { + try { + const cols = dataTable.settings()[0].aoColumns; + const visibleFields = cols + .filter(function (col, idx) { return dataTable.column(idx).visible(); }) + .map(function (col) { return col.sName; }) + .filter(function (name) { return !!name; }); + if (visibleFields.length > 0) { + localStorage.setItem(requestedFieldsStorageKey, JSON.stringify(visibleFields)); + } + // During a saved-view restore, isRestoringState is true and onStateLoaded + // fires a single authoritative reload after all columns are applied. + if (!isRestoringState && cols[columnIdx]?.refreshData) { + clearTimeout(refreshDataTimeout); + refreshDataTimeout = setTimeout(function () { + dataTable.ajax.reload(null, false); + }, 300); + } + } catch { } }); // Attach the draw event to add custom row coloring logic @@ -200,10 +409,14 @@ $(function () { let payment_approve_buttons = dataTable.buttons(['.payment-status']); let payment_check_status_buttons = dataTable.buttons(['.payment-check-status']); let history_button = dataTable.buttons(['.history']); + let cancel_button = abp.auth.isGranted('PaymentsPermissions.Payments.CancelPayment') + ? dataTable.buttons(['.payment-cancel']) + : null; payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); dataTable.on('search.dt', () => handleSearch()); function checkAllRowsHaveState(states) { @@ -286,21 +499,32 @@ $(function () { payment_check_status_buttons.disable(); } let hasHistoricalPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'HistoricalPayment'); - if (dataTable.rows({ selected: true }).indexes().length > 0 && !isInSentState && !hasHistoricalPayment) { - if (abp.auth.isGranted('PaymentsPermissions.Payments.L1ApproveOrDecline') + let hasCancelledPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'Cancelled'); + const hasSelection = dataTable.rows({ selected: true }).indexes().length > 0; + const canApprove = hasSelection && !isInSentState && !hasHistoricalPayment && !hasCancelledPayment + && (abp.auth.isGranted('PaymentsPermissions.Payments.L1ApproveOrDecline') || abp.auth.isGranted('PaymentsPermissions.Payments.L2ApproveOrDecline') - || abp.auth.isGranted('PaymentsPermissions.Payments.L3ApproveOrDecline')) { - payment_approve_buttons.enable(); - + || abp.auth.isGranted('PaymentsPermissions.Payments.L3ApproveOrDecline')); + if (canApprove) { + payment_approve_buttons.enable(); + } else { + payment_approve_buttons.disable(); + } + checkEnableHistoryButton(dataTable, history_button); + + if (cancel_button) { + const eligibleCancelStatuses = ['HistoricalPayment', 'L1Pending', 'L2Pending', 'L3Pending']; + const selectedCount = dataTable.rows({ selected: true }).indexes().length; + if (selectedCount === 1) { + const rowData = dataTable.rows({ selected: true }).data().toArray()[0]; + if (eligibleCancelStatuses.includes(rowData.status)) { + cancel_button.enable(); + } else { + cancel_button.disable(); + } } else { - payment_approve_buttons.disable(); + cancel_button.disable(); } - - checkEnableHistoryButton(dataTable, history_button); - } - else { - payment_approve_buttons.disable(); - checkEnableHistoryButton(dataTable, history_button); } } @@ -324,6 +548,7 @@ $(function () { getBatchNameColumn(columnIndex++), getSubmissionConfirmationCodeColumn(columnIndex++), getApplicantNameColumn(columnIndex++), + getCategoryColumn(columnIndex++), getSupplierNumberColumn(columnIndex++), getSupplierNameColumn(columnIndex++), getSiteNumberColumn(columnIndex++), @@ -350,6 +575,9 @@ $(function () { getNoteColumn(columnIndex++), getAccountDistributionColumn(columnIndex++), getFsbNotifiedColumn(columnIndex++), + getCancelledColumn(columnIndex++), + getCancelledByColumn(columnIndex++), + getCancelledOnColumn(columnIndex++), ] return columns.map((column) => ({ ...column, targets: [column.index], orderData: [column.index, 0] })); @@ -378,6 +606,7 @@ $(function () { name: 'applicantName', data: 'payeeName', className: 'data-table-header', + refreshData: true, index: columnIndex, render: function (data, type, row) { let applicantName = (typeof data !== 'string' || data.trim() === '') ? 'Applicant Name' : data; @@ -420,7 +649,7 @@ $(function () { const safeCode = $.fn.dataTable.render.text().display(code); - if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applicants.ViewList')) { + if (type === 'display' && abp.auth.isGranted('GrantApplicationManagement.Applications')) { const applicationId = row?.correlationId; const isGuid = applicationId && guidPattern.test(applicationId); @@ -464,6 +693,7 @@ $(function () { name: 'siteNumber', data: 'site', className: 'data-table-header', + refreshData: true, index: columnIndex, render: function (data) { return data?.number; @@ -497,6 +727,7 @@ $(function () { name: 'payGroup', data: 'site', className: 'data-table-header', + refreshData: true, index: columnIndex, render: function (data) { if (!data) return ''; @@ -619,6 +850,7 @@ $(function () { name: 'paymentRequesterName', data: 'creatorUser', className: 'data-table-header', + refreshData: true, index: columnIndex, render: function (data) { return formatName(data); @@ -632,6 +864,7 @@ $(function () { name: `l${level}ApproverName`, data: 'expenseApprovals', className: 'data-table-header', + refreshData: true, index: columnIndex, render: function (data) { const approval = getExpenseApprovalsDetails(data, level); @@ -650,6 +883,7 @@ $(function () { name: `l${level}ApprovalDate`, data: 'expenseApprovals', className: 'data-table-header text-nowrap', + refreshData: true, index: columnIndex, render: function (data, type) { let approval = getExpenseApprovalsDetails(data, level); @@ -727,9 +961,10 @@ $(function () { name: 'paymentTags', data: 'paymentTags', className: '', + refreshData: true, index: columnIndex, render: function (data) { - let tagNames = data + let tagNames = (data ?? []) .filter(x => x?.tag?.name) .map(x => x.tag.name); return tagNames.join(', ') ?? ''; @@ -754,6 +989,7 @@ $(function () { name: 'accountCodingDisplay', data: 'accountCodingDisplay', className: 'data-table-header', + refreshData: true, index: columnIndex, render: function (data) { if (data + "" !== "undefined" && data?.length > 0) { @@ -782,11 +1018,28 @@ $(function () { }; } + function getCategoryColumn(columnIndex) { + return { + title: l('ApplicationPaymentListTable:Category'), + name: 'category', + data: 'category', + refreshData: true, + className: 'data-table-header', + index: columnIndex, + render: function (data) { + return data ?? nullPlaceholder; + } + }; + } + function getExpenseApprovalsDetails(expenseApprovals, type) { - return expenseApprovals.find(x => x.type == type); + return (expenseApprovals ?? []).find(x => x.type == type); } $('#search').on('input', function () { + if (isRestoringState) { + return; + } let table = $('#PaymentRequestListTable').DataTable(); table.search($(this).val()).draw(); }); @@ -802,6 +1055,7 @@ $(function () { payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }); @@ -836,6 +1090,9 @@ $(function () { case "Failed": return "#CE3E39"; + case "Cancelled": + return "#6c757d"; + default: return "#053662"; } @@ -858,6 +1115,7 @@ $(function () { payment_approve_buttons.disable(); payment_check_status_buttons.disable(); history_button.disable(); + if (cancel_button) cancel_button.disable(); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); PubSub.publish('clear_selected_payment'); @@ -866,6 +1124,45 @@ $(function () { }); +function getCancelledColumn(columnIndex) { + return { + title: 'Cancelled', + name: 'cancelled', + data: null, + className: 'data-table-header', + index: columnIndex, + render: function (data, type, row) { + return row.status === 'Cancelled' ? 'Cancelled' : ''; + } + }; +} + +function getCancelledByColumn(columnIndex) { + return { + title: 'Cancelled By', + name: 'cancelledBy', + data: 'cancelledBy', + className: 'data-table-header', + index: columnIndex, + render: function (data) { + return data ?? ''; + } + }; +} + +function getCancelledOnColumn(columnIndex) { + return { + title: 'Cancelled On', + name: 'cancelledOn', + data: 'cancelledOn', + className: 'data-table-header', + index: columnIndex, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + }; +} + let casPaymentResponseModal = new abp.ModalManager({ viewUrl: '../PaymentRequests/CasPaymentRequestResponse' }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs index 35c286753a..91e0cc56c0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/BatchPaymentRequests/PaymentRequestAppService_Tests.cs @@ -121,7 +121,7 @@ public async Task GetListAsync_ReturnsPaymentsList() _ = await _paymentRequestRepository.InsertAsync(new PaymentRequest(Guid.NewGuid(), paymentRequestDto), true); // Act - var paymentRequests = await _paymentRequestAppService.GetListAsync(new Volo.Abp.Application.Dtos.PagedAndSortedResultRequestDto() + var paymentRequests = await _paymentRequestAppService.GetListAsync(new PaymentRequestListInputDto() { MaxResultCount = 100 }); @@ -158,7 +158,7 @@ public async Task GetListAsync_ReturnsPagedPaymentsList() _ = await _paymentRequestRepository.InsertAsync(new PaymentRequest(Guid.NewGuid(), paymentRequestDto), true); // Act - var paymentRequests = await _paymentRequestAppService.GetListAsync(new Volo.Abp.Application.Dtos.PagedAndSortedResultRequestDto + var paymentRequests = await _paymentRequestAppService.GetListAsync(new PaymentRequestListInputDto { MaxResultCount = 10, SkipCount = 0, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj index e0871d7f80..b935efb804 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Unity.Payments.Application.Tests.csproj @@ -21,6 +21,8 @@ + + diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js index 432d3339c3..d4511e78f5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.js @@ -10,7 +10,7 @@ $(function () { } // Initialize DataTable for tenant view role management - $('#TenantViewRoleTable').DataTable({ + let tenantViewRoleTable = $('#TenantViewRoleTable').DataTable({ order: [[0, 'asc']], // Sort by tenant name processing: false, serverSide: false, @@ -18,6 +18,8 @@ $(function () { searching: true, pageLength: 25, autoWidth: false, + scrollY: 'calc(100vh - 325px)', + scrollCollapse: true, columnDefs: [ { targets: [2], // Actions column @@ -37,6 +39,14 @@ $(function () { } }); + // Keep the scroll body sized so the header/pagination stay within the viewport + // instead of the table overflowing past the bottom of the screen (same plugin + // used by initializeDataTable's fixedHeaders option elsewhere in the app). + // Stashed on the settings object, matching table-utils.js's own usage of the plugin. + if ($.fn.dataTable.ScrollResize) { + tenantViewRoleTable.settings()[0]._scrollResize = new $.fn.dataTable.ScrollResize(tenantViewRoleTable); + } + // Initialize tooltips on page load initializeTooltips(); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml index f419f5b7a5..f238989357 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.cshtml @@ -7,7 +7,7 @@ @inject IFeatureChecker FeatureChecker @model ReportingConfigurationViewModel -
+
@@ -73,7 +73,7 @@ data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="Generate view" - class="btn unt-btn-primary btn-primary @(Model.HasSavedConfiguration ? "" : "generate-view-btn-hidden")"> + class="btn unt-btn-primary btn-primary btn-compact @(Model.HasSavedConfiguration ? "" : "generate-view-btn-hidden")"> } @@ -86,7 +86,7 @@ data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="Delete this configuration and associated view" - class="btn unt-btn-danger btn-danger report-config-btn-height-fix @(Model.HasSavedConfiguration ? "" : "delete-config-btn-hidden")"> + class="btn unt-btn-danger btn-danger btn-compact @(Model.HasSavedConfiguration ? "" : "delete-config-btn-hidden")"> } @if (await PermissionChecker.IsGrantedAsync(ReportingPermissions.Configuration.Update)) @@ -98,7 +98,7 @@ data-bs-toggle="tooltip" data-bs-placement="top" data-bs-original-title="Save changes" - class="btn unt-btn-primary btn-primary"> + class="btn unt-btn-primary btn-primary btn-compact"> } @if (await PermissionChecker.IsGrantedAsync(ReportingPermissions.Configuration.Update)) {
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css index 11439605b3..aab7f7d7f2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.css @@ -8,6 +8,10 @@ margin-bottom: 0.5rem; } + .provider-toggle-section .btn-compact { + height: 1.28rem !important; + } + .provider-toggle-group { display: flex; @@ -111,7 +115,7 @@ } .column-name-input.is-invalid:focus { - border-color: #dc3545; + border-color: var(--bs-danger); box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); } @@ -120,8 +124,7 @@ width: 100%; margin-top: 0.25rem; font-size: 0.875rem; - color: #dc3545; - display: block; + color: var(--bs-danger); } .valid-feedback { diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs index 913fe23187..b61eba84f8 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnitySelector.Override.cs @@ -49,4 +49,15 @@ public static partial class Update } } } + + public static partial class Application + { + public static partial class Status + { + public const string Default = "Unity.GrantManager.ApplicationManagement.Application.Status"; + public const string Publish = "Unity.GrantManager.ApplicationManagement.Application.Status.Publish"; + public const string Unpublish = "Unity.GrantManager.ApplicationManagement.Application.Status.Unpublish"; + public const string BulkPublish = "Unity.GrantManager.ApplicationManagement.Application.Status.BulkPublish"; + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs index f4e48b303d..cafbfba345 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Features/FeatureConsts.cs @@ -3,5 +3,6 @@ public static class FeatureConsts { public const string Reporting = "Unity.Reporting"; + public const string Onboarding = "Unity.Onboarding"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs new file mode 100644 index 0000000000..4c2a541385 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs @@ -0,0 +1,104 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Unity.Modules.Shared.Specializations; +using Volo.Abp.Features; +using Volo.Abp.UI.Navigation; + +namespace Unity.Modules.Shared.Navigation; + +public static class MenuItemExtensions +{ + private const string ExcludeWhenFeaturesKey = "_ExcludeWhenFeatures"; + private const string OnlyWhenFeaturesKey = "_OnlyWhenFeatures"; + private const string ExcludeWhenSpecializationsKey = "_ExcludeWhenSpecializations"; + private const string OnlyWhenSpecializationsKey = "_OnlyWhenSpecializations"; + + /// + /// Hides this menu item when any of the given features are enabled. + /// + public static ApplicationMenuItem ExcludeWhenFeatures( + this ApplicationMenuItem item, + params string[] featureNames) + { + item.CustomData[ExcludeWhenFeaturesKey] = featureNames; + return item; + } + + /// + /// Shows this menu item only when all of the given features are enabled. + /// + public static ApplicationMenuItem OnlyWhenFeatures( + this ApplicationMenuItem item, + params string[] featureNames) + { + item.CustomData[OnlyWhenFeaturesKey] = featureNames; + return item; + } + + /// + /// Hides this menu item when any of the given specializations are enabled. + /// + public static ApplicationMenuItem ExcludeWhenSpecializations( + this ApplicationMenuItem item, + params string[] specializationNames) + { + item.CustomData[ExcludeWhenSpecializationsKey] = specializationNames; + return item; + } + + /// + /// Shows this menu item only when all of the given specializations are enabled. + /// + public static ApplicationMenuItem OnlyWhenSpecializations( + this ApplicationMenuItem item, + params string[] specializationNames) + { + item.CustomData[OnlyWhenSpecializationsKey] = specializationNames; + return item; + } + + /// + /// Adds the item to the menu, respecting any feature or specialization visibility declarations. + /// + public static async Task AddItemAsync( + this MenuConfigurationContext context, + ApplicationMenuItem item) + { + var featureChecker = context.ServiceProvider.GetRequiredService(); + var specializationChecker = context.ServiceProvider.GetRequiredService(); + + if (item.CustomData.TryGetValue(ExcludeWhenFeaturesKey, out var excludeFeatObj) + && excludeFeatObj is string[] excludeFeatures) + { + foreach (var feature in excludeFeatures) + if (await featureChecker.IsEnabledAsync(feature)) + return; + } + + if (item.CustomData.TryGetValue(OnlyWhenFeaturesKey, out var onlyFeatObj) + && onlyFeatObj is string[] onlyFeatures) + { + foreach (var feature in onlyFeatures) + if (!await featureChecker.IsEnabledAsync(feature)) + return; + } + + if (item.CustomData.TryGetValue(ExcludeWhenSpecializationsKey, out var excludeSpecObj) + && excludeSpecObj is string[] excludeSpecs) + { + foreach (var spec in excludeSpecs) + if (await specializationChecker.IsEnabledAsync(spec)) + return; + } + + if (item.CustomData.TryGetValue(OnlyWhenSpecializationsKey, out var onlySpecObj) + && onlySpecObj is string[] onlySpecs) + { + foreach (var spec in onlySpecs) + if (!await specializationChecker.IsEnabledAsync(spec)) + return; + } + + context.Menu.AddItem(item); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/ISpecializationChecker.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/ISpecializationChecker.cs new file mode 100644 index 0000000000..57e0acb434 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/ISpecializationChecker.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.Specializations; + +public interface ISpecializationChecker +{ + Task IsEnabledAsync(string name); +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationChecker.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationChecker.cs new file mode 100644 index 0000000000..0f29fa7b1c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationChecker.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Features; + +namespace Unity.Modules.Shared.Specializations; + +public class SpecializationChecker(IFeatureChecker featureChecker) : ISpecializationChecker, ITransientDependency +{ + public Task IsEnabledAsync(string name) => featureChecker.IsEnabledAsync(name); +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationConsts.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationConsts.cs new file mode 100644 index 0000000000..eb95bab581 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Specializations/SpecializationConsts.cs @@ -0,0 +1,8 @@ +namespace Unity.Modules.Shared.Specializations; + +public static class SpecializationConsts +{ + public const string GroupName = "Specializations"; + + public const string Onboarding = "Unity.Onboarding"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs index 3eb2e66341..86466a1d01 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs @@ -5,20 +5,17 @@ namespace Unity.Modules.Shared.Utils; public static class DateTimeExtensions { - private const string WindowsPacificId = "Pacific Standard Time"; - private const string IanaPacificId = "America/Los_Angeles"; + // BC Pacific timezone: PST does NOT observe DST in 2026 — fixed UTC-8 year-round. + private static readonly TimeSpan BcPstOffset = TimeSpan.FromHours(-8); - // Lazy-initialized cached timezone to avoid repeated OS lookups. - private static readonly Lazy PacificTimeZone = new(GetPacificTimeZone, isThreadSafe: true); + // BC Mountain timezone: Peace River / NE BC region — MST/MDT, DST still applies. + private const string WindowsMountainId = "Mountain Standard Time"; + private const string IanaMountainId = "America/Edmonton"; + private static readonly Lazy MountainTimeZone = new(GetMountainTimeZone, isThreadSafe: true); /// /// Formats a nullable value as an ISO 8601-compliant UTC timestamp. /// - /// If the provided is not already in UTC, it will be treated as a - /// local time and converted to UTC before formatting. - /// The nullable to format. If the value is not in UTC, it will be converted to UTC. - /// A string representation of the in ISO 8601 format, or an empty string if is . public static string FormatTimestamp(DateTime? utcTime) { if (!utcTime.HasValue) @@ -32,16 +29,13 @@ public static string FormatTimestamp(DateTime? utcTime) } /// - /// Converts a given UTC time to Pacific Time and formats it as a string with the appropriate time zone - /// abbreviation. + /// Converts a given UTC time to BC Pacific Standard Time and formats it as a string. + /// BC's Pacific timezone does NOT observe Daylight Saving Time in 2026; PST (UTC-8) + /// is applied year-round. For the Peace River / NE BC region (Mountain Time), use + /// instead. /// - /// The method ensures that the input is treated as UTC. If the input - /// time is not explicitly marked as UTC, it is converted to UTC before performing the time zone - /// conversion. /// The UTC time to convert. If , an empty string is returned. - /// A string representing the Pacific Time equivalent of the provided UTC time, formatted as "yyyy-MM-dd h:mm tt" - /// followed by the time zone abbreviation "(PDT)" for daylight saving time or "(PST)" for standard time. Returns an - /// empty string if is . + /// A string formatted as "yyyy-MM-dd h:mm tt (PST)". public static string FormatPacificTime(DateTime? utcTime) { if (!utcTime.HasValue) @@ -51,30 +45,52 @@ public static string FormatPacificTime(DateTime? utcTime) ? utcTime.Value : DateTime.SpecifyKind(utcTime.Value, DateTimeKind.Utc); - var pacificTimeZone = PacificTimeZone.Value; - var pacificDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcTimeValue, pacificTimeZone); + // BC PST is a fixed UTC-8 offset — no DST adjustment in 2026. + var bcPstDateTime = new DateTimeOffset(utcTimeValue, TimeSpan.Zero).ToOffset(BcPstOffset); - string timeZoneAbbreviation = pacificTimeZone.IsDaylightSavingTime(pacificDateTime) ? "(PDT)" : "(PST)"; - return $"{pacificDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {timeZoneAbbreviation}"; + return $"{bcPstDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} (PST)"; } - private static TimeZoneInfo GetPacificTimeZone() + /// + /// Converts a given UTC time to BC Mountain Time (Peace River / NE BC region) and formats it. + /// Unlike BC's Pacific zone, the Mountain timezone in BC DOES observe Daylight Saving Time in 2026: + /// MST (UTC-7) in winter, MDT (UTC-6) in summer. + /// + /// The UTC time to convert. If , an empty string is returned. + /// A string formatted as "yyyy-MM-dd h:mm tt (MST)" or "yyyy-MM-dd h:mm tt (MDT)". + public static string FormatMountainTime(DateTime? utcTime) + { + if (!utcTime.HasValue) + return string.Empty; + + var utcTimeValue = utcTime.Value.Kind == DateTimeKind.Utc + ? utcTime.Value + : DateTime.SpecifyKind(utcTime.Value, DateTimeKind.Utc); + + var mountainTz = MountainTimeZone.Value; + var mtDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcTimeValue, mountainTz); + string abbr = mountainTz.IsDaylightSavingTime(mtDateTime) ? "(MDT)" : "(MST)"; + + return $"{mtDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {abbr}"; + } + + private static TimeZoneInfo GetMountainTimeZone() { - // If running on Windows, attempt Windows ID first; otherwise attempt IANA first. if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - if (TryFindTimeZone(WindowsPacificId, out var tz)) return tz; - if (TryFindTimeZone(IanaPacificId, out tz)) return tz; + if (TryFindTimeZone(WindowsMountainId, out var tz)) return tz; + if (TryFindTimeZone(IanaMountainId, out tz)) return tz; } else { - if (TryFindTimeZone(IanaPacificId, out var tz)) return tz; - if (TryFindTimeZone(WindowsPacificId, out tz)) return tz; + if (TryFindTimeZone(IanaMountainId, out var tz)) return tz; + if (TryFindTimeZone(WindowsMountainId, out tz)) return tz; } throw new TimeZoneNotFoundException( - $"Neither '{WindowsPacificId}' nor '{IanaPacificId}' time zone IDs were found on this system."); + $"Neither '{WindowsMountainId}' nor '{IanaMountainId}' time zone IDs were found on this system."); } + private static bool TryFindTimeZone(string id, out TimeZoneInfo tz) { try diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/StringExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/StringExtensions.cs index d28d8f5080..494ca8d46e 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/StringExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/StringExtensions.cs @@ -61,5 +61,18 @@ public static double CompareStrings(this string str1, string str2) return emailAddressList.Count > 0 ? emailAddressList : null; } + + /// + /// Capitalizes the first character and lowercases the remaining characters. + /// + /// The string to capitalize + /// String with first character uppercase and rest lowercase, or empty if input is null/empty + public static string Capitalize(this string? inputString) + { + if (string.IsNullOrEmpty(inputString)) + return string.Empty; + + return char.ToUpper(inputString[0]) + inputString.Substring(1).ToLower(); + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ColumnFilterDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ColumnFilterDto.cs new file mode 100644 index 0000000000..95cd6b7bfa --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ColumnFilterDto.cs @@ -0,0 +1,7 @@ +namespace Unity.TenantManagement; + +public class ColumnFilterDto +{ + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs new file mode 100644 index 0000000000..75fd8f7eb2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingApplicationProvider.cs @@ -0,0 +1,26 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; + +namespace Unity.TenantManagement; + +public interface IOnboardingApplicationProvider +{ + Task> GetPagedListAsync( + int skipCount, + int maxResultCount, + string? sorting, + string category, + string? filter = null, + IReadOnlyList? globalDynamicMatchIds = null, + IReadOnlyList? staticColumnFilters = null, + IReadOnlyList? dynamicColumnMatchIds = null); + Task GetByIdAsync(Guid id); + Task> GetAllIdsAsync(string category); + Task> GetFormVersionIdsAsync(string category); + Task> GetMappedCoreFieldColumnsAsync(string category); + Task> GetAvailableCategoriesAsync(); + Task CloseApplicationAsync(Guid applicationId); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs new file mode 100644 index 0000000000..10cfd4acc0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingRequestAppService.cs @@ -0,0 +1,18 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace Unity.TenantManagement; + +public interface IOnboardingRequestAppService : IApplicationService +{ + Task> GetListAsync(OnboardingListRequestDto input); + Task GetAsync(Guid id); + Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null); + Task CreateTenantAsync(Guid id, CreateTenantInputDto? input); + Task GetColumnSchemaAsync(string? category = null); + Task> GetAvailableCategoriesAsync(); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingUserLookup.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingUserLookup.cs new file mode 100644 index 0000000000..155c75194d --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingUserLookup.cs @@ -0,0 +1,11 @@ +#nullable enable +using System.Threading.Tasks; + +namespace Unity.TenantManagement; + +/// Thin seam over the CSS directory API for onboarding use cases. +public interface IOnboardingUserLookup +{ + /// Returns the IDIR user GUID if the email resolves in the directory, null otherwise. + Task FindUserGuidByEmailAsync(string email); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingValidationStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingValidationStep.cs new file mode 100644 index 0000000000..d5097e4798 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/IOnboardingValidationStep.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace Unity.TenantManagement; + +public interface IOnboardingValidationStep +{ + int Order { get; } + string StepName { get; } + Task ValidateAsync(OnboardingRequestDto request); +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs index ae68094937..bea5f47225 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantAppService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.Application.Services; @@ -6,8 +7,8 @@ namespace Unity.TenantManagement; public interface ITenantAppService : ICrudAppService { - Task GetDefaultConnectionStringAsync(Guid id); - Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString); - Task DeleteDefaultConnectionStringAsync(Guid id); Task AssignManagerAsync(TenantAssignManagerDto managerAssignment); + Task GetConnectionStringsAsync(Guid id); + Task UpdateConnectionStringsAsync(Guid id, TenantConnectionStringsDto input); + Task> GetManagersAsync(Guid id); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs index 10d36e8ec5..c5b82716f8 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/ITenantConnectionStringBuilder.cs @@ -1,9 +1,14 @@ -using Volo.Abp.Application.Services; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; namespace Unity.TenantManagement.Application.Contracts { public interface ITenantConnectionStringBuilder : IApplicationService { - string Build(string tenantName); + Task GenerateCredentialsAsync(); + + TenantDbCredentials GenerateReadOnlyCredentials(TenantDbCredentials credentials); + + string Build(string tenantName, TenantDbCredentials credentials); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs new file mode 100644 index 0000000000..6310942477 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingApplicationRecord.cs @@ -0,0 +1,15 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace Unity.TenantManagement; + +public class OnboardingApplicationRecord +{ + public Guid Id { get; set; } + public string ReferenceNo { get; set; } = string.Empty; + public DateTime SubmissionDate { get; set; } + public string Status { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public Dictionary CoreFieldValues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs new file mode 100644 index 0000000000..2a95c6a0af --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingColumnConfigDto.cs @@ -0,0 +1,33 @@ +#nullable enable +using System.Collections.Generic; + +namespace Unity.TenantManagement; + +public class OnboardingColumnDto +{ + public string Key { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public bool Selected { get; set; } +} + +public class OnboardingColumnSchemaDto +{ + public List Columns { get; set; } = []; + public string? TenantNameFieldKey { get; set; } + public string? SuperUsersFieldKey { get; set; } + public string? BranchFieldKey { get; set; } + public string? FeaturesFieldKey { get; set; } + public string? MinistryFieldKey { get; set; } + public string? ProgramAreaFieldKey { get; set; } +} + +public class CreateTenantInputDto +{ + public string? TenantNameFieldKey { get; set; } + public string? SuperUsersFieldKey { get; set; } + public string? BranchFieldKey { get; set; } + public string? FeaturesFieldKey { get; set; } + public string? MinistryFieldKey { get; set; } + public string? ProgramAreaFieldKey { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingListRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingListRequestDto.cs new file mode 100644 index 0000000000..8abeaea7d9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingListRequestDto.cs @@ -0,0 +1,12 @@ +#nullable enable +using System.Collections.Generic; +using Volo.Abp.Application.Dtos; + +namespace Unity.TenantManagement; + +public class OnboardingListRequestDto : PagedAndSortedResultRequestDto +{ + public string? Category { get; set; } = "Onboarding"; + public string? Filter { get; set; } + public List? ColumnFilters { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs new file mode 100644 index 0000000000..c251999c11 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingRequestDto.cs @@ -0,0 +1,25 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace Unity.TenantManagement; + +public class OnboardingRequestDto +{ + public Guid Id { get; set; } + public string SubmissionNumber { get; set; } = string.Empty; + public string TenantName { get; set; } = string.Empty; + public string TenantDescription { get; set; } = string.Empty; + public string ProgramAreaName { get; set; } = string.Empty; + public string ProgramAreaDescription { get; set; } = string.Empty; + public string Contacts { get; set; } = string.Empty; + public string Features { get; set; } = string.Empty; + public string SuperUsers { get; set; } = string.Empty; + public string ExecutiveDirector { get; set; } = string.Empty; + public string Branch { get; set; } = string.Empty; + public string Ministry { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public DateTime? SubmissionDate { get; set; } + public Dictionary Fields { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationResultDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationResultDto.cs new file mode 100644 index 0000000000..aeed66c0c6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationResultDto.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Unity.TenantManagement; + +public class OnboardingValidationResultDto +{ + public bool IsValid { get; set; } + public List Issues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationStepResult.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationStepResult.cs new file mode 100644 index 0000000000..eb28626bbb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/OnboardingValidationStepResult.cs @@ -0,0 +1,11 @@ +#nullable enable +namespace Unity.TenantManagement; + +public class OnboardingValidationStepResult +{ + public bool IsValid { get; set; } + public string? Issue { get; set; } + + public static OnboardingValidationStepResult Success() => new() { IsValid = true }; + public static OnboardingValidationStepResult Failure(string issue) => new() { IsValid = false, Issue = issue }; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantConnectionStringsDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantConnectionStringsDto.cs new file mode 100644 index 0000000000..60464c9de7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantConnectionStringsDto.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace Unity.TenantManagement; + +public class TenantConnectionStringsDto +{ + public string? TenantConnectionString { get; set; } + public string? ReadOnlyConnectionString { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs index e76eedd858..d9b7998e85 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantCreateDto.cs @@ -1,6 +1,9 @@ -namespace Unity.TenantManagement; +#nullable enable +namespace Unity.TenantManagement; public class TenantCreateDto : TenantCreateOrUpdateDtoBase { - public string UserIdentifier { get; set; } + public string UserIdentifier { get; set; } = string.Empty; + /// Comma-separated ABP feature keys to enable on the new tenant (e.g. "Unity.Payments,Unity.Reporting"). + public string? FeatureKeys { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs new file mode 100644 index 0000000000..3c2fa35c8e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDbCredentials.cs @@ -0,0 +1,16 @@ +namespace Unity.TenantManagement.Application.Contracts +{ + public class TenantDbCredentials + { + public TenantDbCredentials(string dbName, string username, string password) + { + DbName = dbName; + Username = username; + Password = password; + } + + public string DbName { get; } + public string Username { get; } + public string Password { get; } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs index b1aaf19b9e..5dc338b00c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantDto.cs @@ -11,5 +11,6 @@ public class TenantDto : ExtensibleEntityDto, IHasConcurrencyStamp public string Branch { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public string CasClientCode { get; set; } = string.Empty; + public string LicencePlate { get; set; } = string.Empty; public string ConcurrencyStamp { get; set; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs index 4b10adc481..12b1decc2f 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagementPermissions.cs @@ -18,6 +18,13 @@ public static class Tenants public const string ManageEndpoints = AbpGroupName + ".Tenants" + ".ManageEndpoints"; } + public static class Policies + { + public const string TenantsOrITOps = "TenantManagement.TenantsOrITOps"; + public const string TenantsUpdateOrITOps = "TenantManagement.TenantsUpdateOrITOps"; + public const string TenantsCreateOrITOps = "TenantManagement.TenantsCreateOrITOps"; + } + public static string[] GetAll() { return ReflectionHelper.GetPublicConstantsRecursively(typeof(TenantManagementPermissions)); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagerDto.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagerDto.cs new file mode 100644 index 0000000000..3dd083f1e1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/TenantManagerDto.cs @@ -0,0 +1,7 @@ +namespace Unity.TenantManagement; + +public class TenantManagerDto +{ + public string DisplayName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs new file mode 100644 index 0000000000..65257f69b3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/EncryptedTenantConnectionStringResolver.cs @@ -0,0 +1,50 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Encryption; + +namespace Unity.TenantManagement.Application; + +[Dependency(ReplaceServices = true)] +[ExposeServices(typeof(IConnectionStringResolver))] +public class EncryptedTenantConnectionStringResolver : MultiTenantConnectionStringResolver +{ + private readonly IStringEncryptionService _encryptionService; + + public EncryptedTenantConnectionStringResolver( + IOptionsMonitor options, + ICurrentTenant currentTenant, + IServiceProvider serviceProvider, + IStringEncryptionService encryptionService) + : base(options, currentTenant, serviceProvider) + { + _encryptionService = encryptionService; + } + + public override async Task ResolveAsync(string connectionStringName = null) + { + var value = await base.ResolveAsync(connectionStringName); + if (string.IsNullOrEmpty(value)) return value; + if (PlainConnectionStringDetector.LooksLikePlainConnectionString(value)) return value; + + try + { + return _encryptionService.Decrypt(value); + } + catch (FormatException) + { + // Not valid base64, so it can't be ciphertext - it's plain text. + return value; + } + catch (CryptographicException) + { + // Valid base64 but failed to decrypt (wrong passphrase/corrupted ciphertext) - + // fall back to the raw value rather than breaking connection resolution. + return value; + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs new file mode 100644 index 0000000000..c0000d45fc --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettingDefinitionProvider.cs @@ -0,0 +1,23 @@ +using Volo.Abp.Settings; +using Volo.Abp.SettingManagement; + +namespace Unity.TenantManagement.Onboarding; + +public class OnboardingColumnConfigSettingDefinitionProvider : SettingDefinitionProvider +{ + private static SettingDefinition OnboardingDef(string name) => + new SettingDefinition(name, defaultValue: null, isVisibleToClients: false, isInherited: false, isEncrypted: false) + .WithProviders(GlobalSettingValueProvider.ProviderName, UserSettingValueProvider.ProviderName); + + public override void Define(ISettingDefinitionContext context) + { + context.Add( + OnboardingDef(OnboardingColumnConfigSettings.TenantNameFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.SuperUsersFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.BranchFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.FeaturesFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.MinistryFieldKey), + OnboardingDef(OnboardingColumnConfigSettings.ProgramAreaFieldKey) + ); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs new file mode 100644 index 0000000000..a2041e1c22 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingColumnConfigSettings.cs @@ -0,0 +1,11 @@ +namespace Unity.TenantManagement.Onboarding; + +public static class OnboardingColumnConfigSettings +{ + public const string TenantNameFieldKey = "Onboarding.ColumnConfig.TenantNameFieldKey"; + public const string SuperUsersFieldKey = "Onboarding.ColumnConfig.SuperUsersFieldKey"; + public const string BranchFieldKey = "Onboarding.ColumnConfig.BranchFieldKey"; + public const string FeaturesFieldKey = "Onboarding.ColumnConfig.FeaturesFieldKey"; + public const string MinistryFieldKey = "Onboarding.ColumnConfig.MinistryFieldKey"; + public const string ProgramAreaFieldKey = "Onboarding.ColumnConfig.ProgramAreaFieldKey"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs new file mode 100644 index 0000000000..0bf2720292 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Onboarding/OnboardingFeatureMap.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.TenantManagement.Onboarding; + +internal static class OnboardingFeatureMap +{ + private static readonly Dictionary _map = new(StringComparer.OrdinalIgnoreCase) + { + // Display name / delimited-string variants + ["Payments"] = "Unity.Payments", + ["Notifications"] = "Unity.Notifications", + ["Flex"] = "Unity.Flex", + ["Reporting"] = "Unity.Reporting", + ["AI Reporting"] = "Unity.AIReporting", + ["AI Attachment Summaries"] = "Unity.AI.AttachmentSummaries", + ["AI Application Analysis"] = "Unity.AI.ApplicationAnalysis", + ["AI Scoring"] = "Unity.AI.Scoring", + ["Analytics"] = "Unity.Analytics", + // camelCase key aliases used by the checkbox-group JSON format + ["aiReporting"] = "Unity.AIReporting", + ["aiAttachmentSummaries"] = "Unity.AI.AttachmentSummaries", + ["aiApplicationAnalysis"] = "Unity.AI.ApplicationAnalysis", + ["aiScoring"] = "Unity.AI.Scoring", + }; + + private static readonly char[] _delimiters = [',', ';', '|']; + + private sealed class CheckboxItem + { + [JsonPropertyName("key")] public string Key { get; set; } = string.Empty; + [JsonPropertyName("value")] public bool Value { get; set; } + } + + /// + /// Accepts either a JSON checkbox-group array or a delimited string (comma / semicolon / pipe) + /// and returns the ABP feature keys for all enabled/listed recognised names. + /// + public static IReadOnlyList ResolveFeatureKeys(string features) + { + if (string.IsNullOrWhiteSpace(features)) + return []; + + IEnumerable tokens; + var trimmed = features.Trim(); + + if (trimmed.StartsWith('[')) + { + try + { + var items = JsonSerializer.Deserialize>(trimmed); + tokens = items?.Where(i => i.Value).Select(i => i.Key) ?? []; + } + catch + { + tokens = []; + } + } + else + { + tokens = trimmed.Split(_delimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + return [.. tokens + .Select(token => _map.TryGetValue(token, out var key) ? key : null) + .Where(key => key is not null) + .Cast() + .Distinct()]; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs new file mode 100644 index 0000000000..7b60631ad3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/OnboardingRequestAppService.cs @@ -0,0 +1,561 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Values; +using Unity.Flex.WorksheetInstances; +using Unity.Modules.Shared.Correlation; +using Unity.Modules.Shared.Permissions; +using Unity.TenantManagement.Onboarding; +using Unity.TenantManagement.Validation; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; +using Volo.Abp.SettingManagement; + +namespace Unity.TenantManagement; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class OnboardingRequestAppService( + IEnumerable validationSteps, + ISettingManager settingManager +) : ApplicationService, IOnboardingRequestAppService +{ + private readonly IEnumerable _validationSteps = validationSteps; + private readonly ISettingManager _settingManager = settingManager; + + private const string DefaultCategory = "Onboarding"; + private const string ApplicationCorrelationProvider = "Application"; + private const string UserProvider = "U"; + + private ITenantAppService TenantAppService => + LazyServiceProvider.LazyGetRequiredService(); + + private IOnboardingUserLookup? UserLookup => + LazyServiceProvider.LazyGetService(); + + private IOnboardingApplicationProvider? ApplicationProvider => + LazyServiceProvider.LazyGetService(); + + private IWorksheetAppService WorksheetAppService => + LazyServiceProvider.LazyGetRequiredService(); + + private IWorksheetInstanceAppService WorksheetInstanceAppService => + LazyServiceProvider.LazyGetRequiredService(); + + public virtual async Task> GetListAsync(OnboardingListRequestDto input) + { + if (ApplicationProvider == null) + return new PagedResultDto(0, []); + + var category = string.IsNullOrWhiteSpace(input.Category) ? DefaultCategory : input.Category; + + // Core fields are real Application/Applicant columns — the provider can filter/sort + // them directly in SQL, same as the always-static columns. Only worksheet fields (JSONB) + // need the in-memory scan below. + var coreFieldKeys = (await ApplicationProvider.GetMappedCoreFieldColumnsAsync(category)) + .Select(c => c.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + bool IsProviderColumn(string name) => IsStaticColumn(name) || coreFieldKeys.Contains(name); + + bool hasGlobalFilter = !string.IsNullOrWhiteSpace(input.Filter); + var staticColumnFilters = GetStaticColumnFilters(input.ColumnFilters, IsProviderColumn); + var dynamicColumnFilters = GetDynamicColumnFilters(input.ColumnFilters, IsProviderColumn); + var (sortField, sortDescending) = ParseSorting(input.Sorting); + bool isDynamicSort = sortField != null && !IsProviderColumn(sortField); + + IReadOnlyList? globalDynamicMatchIds = null; + IReadOnlyList? dynamicColumnMatchIds = null; + Dictionary>? allInstancesByApp = null; + + // PERF: global filter, dynamic worksheet-field filters, and dynamic sort all require + // loading every application id + worksheet instance for the category into memory, since + // worksheet field values live in JSONB and can't be filtered/sorted in SQL. Fine at current + // onboarding-request volumes; revisit (e.g. bounded fetch, indexed projection) if this + // screen is reused/refactored for a larger dataset. + if (hasGlobalFilter || dynamicColumnFilters.Count > 0 || isDynamicSort) + { + var allIds = await ApplicationProvider.GetAllIdsAsync(category); + + if (allIds.Count > 0) + { + var allInstances = await WorksheetInstanceAppService + .GetListByCorrelationIdsAsync(allIds, ApplicationCorrelationProvider); + + allInstancesByApp = allInstances + .GroupBy(wi => wi.CorrelationId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + + if (hasGlobalFilter) + { + globalDynamicMatchIds = allIds + .Where(id => MatchesGlobalInWorksheet( + allInstancesByApp.GetValueOrDefault(id, []), input.Filter!)) + .ToList(); + } + + if (dynamicColumnFilters.Count > 0) + { + dynamicColumnMatchIds = allIds + .Where(id => dynamicColumnFilters.All(cf => + MatchesColumnInWorksheet( + allInstancesByApp.GetValueOrDefault(id, []), cf.Name, cf.Value))) + .ToList(); + } + } + else if (dynamicColumnFilters.Count > 0) + { + dynamicColumnMatchIds = []; // No apps → nothing matches dynamic filters + } + } + + PagedResultDto appPage; + + if (isDynamicSort) + { + // Worksheet field values live in JSONB, not a queryable column, so the DB can't + // sort by them — fetch every filtered match and sort/page in memory instead. + // PERF: int.MaxValue means no upper bound on this fetch — acceptable while onboarding + // request volume stays small; cap this if that changes. + var filtered = await ApplicationProvider.GetPagedListAsync( + 0, int.MaxValue, null, category, + hasGlobalFilter ? input.Filter : null, + globalDynamicMatchIds, + staticColumnFilters.Count > 0 ? staticColumnFilters : null, + dynamicColumnMatchIds); + + IOrderedEnumerable ordered = sortDescending + ? filtered.Items.OrderByDescending( + a => GetWorksheetFieldValue(allInstancesByApp?.GetValueOrDefault(a.Id, []) ?? [], sortField!), + StringComparer.OrdinalIgnoreCase) + : filtered.Items.OrderBy( + a => GetWorksheetFieldValue(allInstancesByApp?.GetValueOrDefault(a.Id, []) ?? [], sortField!), + StringComparer.OrdinalIgnoreCase); + + var pagedItems = ordered.Skip(input.SkipCount).Take(input.MaxResultCount).ToList(); + appPage = new PagedResultDto(filtered.TotalCount, pagedItems); + } + else + { + // Reconstruct from the already-parsed (prefix-stripped) field rather than passing + // input.Sorting raw — core-field columns are sent by the client as "fields.", + // same as worksheet columns, and the provider only knows the bare field name. + var providerSorting = sortField != null ? $"{sortField} {(sortDescending ? "DESC" : "ASC")}" : null; + + appPage = await ApplicationProvider.GetPagedListAsync( + input.SkipCount, input.MaxResultCount, providerSorting, category, + hasGlobalFilter ? input.Filter : null, + globalDynamicMatchIds, + staticColumnFilters.Count > 0 ? staticColumnFilters : null, + dynamicColumnMatchIds); + } + + if (appPage.TotalCount == 0) + return new PagedResultDto(0, []); + + var applicationIds = appPage.Items.Select(a => a.Id).ToList(); + var worksheetInstances = await WorksheetInstanceAppService + .GetListByCorrelationIdsAsync(applicationIds, ApplicationCorrelationProvider); + + var instancesByApp = worksheetInstances + .GroupBy(wi => wi.CorrelationId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var mapping = await ReadTenantMappingAsync(); + var items = appPage.Items + .Select(app => MapToDto( + app, + instancesByApp.GetValueOrDefault(app.Id, []), + mapping)) + .ToList(); + + return new PagedResultDto(appPage.TotalCount, items); + } + + public virtual async Task GetAsync(Guid id) + { + if (ApplicationProvider == null) return null; + + var app = await ApplicationProvider.GetByIdAsync(id); + if (app == null) return null; + + var worksheetInstances = await WorksheetInstanceAppService + .GetListByCorrelationIdsAsync([id], ApplicationCorrelationProvider); + + var mapping = await ReadTenantMappingAsync(); + return MapToDto(app, worksheetInstances, mapping); + } + + public virtual async Task GetColumnSchemaAsync(string? category = null) + { + if (ApplicationProvider == null) + return new OnboardingColumnSchemaDto { Columns = [] }; + + var resolvedCategory = string.IsNullOrWhiteSpace(category) ? DefaultCategory : category; + var formVersionIds = await ApplicationProvider.GetFormVersionIdsAsync(resolvedCategory); + + if (formVersionIds.Count == 0) + return new OnboardingColumnSchemaDto { Columns = [] }; + + var seenWorksheetIds = new HashSet(); + // Columns are combined across form versions by key alone — two versions mapping the + // same key are treated as the same column even if the label/worksheet differs between + // them. Whichever version is encountered first wins the displayed label/type. + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + var columns = new List(); + + foreach (var formVersionId in formVersionIds) + { + List worksheets; + try { worksheets = await WorksheetAppService.GetListByCorrelationAsync(formVersionId, CorrelationConsts.FormVersion); } + catch { continue; } + + foreach (var worksheet in worksheets) + { + if (!seenWorksheetIds.Add(worksheet.Id)) continue; + + foreach (var field in worksheet.Sections + .OrderBy(s => s.Order) + .SelectMany(s => s.Fields.OrderBy(f => f.Order)) + .Where(f => f.Enabled)) + { + if (!seenKeys.Add(field.Key)) continue; + columns.Add(new OnboardingColumnDto + { + Key = field.Key, + Label = field.Label, + Type = field.Type.ToString(), + Selected = true + }); + } + } + } + + foreach (var coreColumn in await ApplicationProvider.GetMappedCoreFieldColumnsAsync(resolvedCategory)) + { + if (!seenKeys.Add(coreColumn.Key)) continue; + columns.Add(coreColumn); + } + + var saved = await ReadTenantMappingAsync(); + saved.Columns = columns; + return saved; + } + + public virtual async Task> GetAvailableCategoriesAsync() + { + if (ApplicationProvider == null) return [DefaultCategory]; + var categories = await ApplicationProvider.GetAvailableCategoriesAsync(); + // Always ensure "Onboarding" is present even if no forms carry it yet + if (!categories.Contains(DefaultCategory)) + categories.Insert(0, DefaultCategory); + return categories; + } + + public virtual async Task ValidateAsync(Guid id, string? tenantNameFieldKey, string? superUsersFieldKey, string? branchFieldKey = null, string? featuresFieldKey = null, string? ministryFieldKey = null, string? programAreaFieldKey = null) + { + var request = await GetAsync(id); + if (request == null) + return new OnboardingValidationResultDto { IsValid = false, Issues = ["Onboarding request not found."] }; + + await ResolveFieldMappings(request, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + + var issues = await RunValidationStepsAsync(request); + + return new OnboardingValidationResultDto { IsValid = issues.Count == 0, Issues = issues }; + } + + public virtual async Task CreateTenantAsync(Guid id, CreateTenantInputDto? input) + { + var request = await GetAsync(id) + ?? throw new UserFriendlyException("Onboarding request not found."); + + await ResolveFieldMappings(request, input?.TenantNameFieldKey, input?.SuperUsersFieldKey, input?.BranchFieldKey, input?.FeaturesFieldKey, input?.MinistryFieldKey, input?.ProgramAreaFieldKey); + + if (input != null) + await SaveFieldMappingAsync(input.TenantNameFieldKey, input.SuperUsersFieldKey, input.BranchFieldKey, input.FeaturesFieldKey, input.MinistryFieldKey, input.ProgramAreaFieldKey); + + // Re-validate server-side even if the client already called ValidateAsync — the client + // cannot be trusted to have done so, and skipping this would let a duplicate tenant name + // or other validation-step failure slip through directly via this endpoint. + var validationIssues = await RunValidationStepsAsync(request); + if (validationIssues.Count > 0) + throw new UserFriendlyException(string.Join(" ", validationIssues)); + + var emails = SuperUsersValidationStep.ParseEmails(request.SuperUsers); + + var userGuids = new List(); + if (UserLookup is not null) + { + foreach (var email in emails) + { + var guid = await UserLookup.FindUserGuidByEmailAsync(email); + if (!string.IsNullOrWhiteSpace(guid)) + userGuids.Add(guid); + } + } + + if (userGuids.Count == 0) + throw new UserFriendlyException("No valid super users could be resolved. Cannot create tenant without at least one valid program manager."); + + var featureKeys = OnboardingFeatureMap.ResolveFeatureKeys(request.Features); + + var tenantDto = await TenantAppService.CreateAsync(new TenantCreateDto + { + Name = request.TenantName, + Branch = request.Branch, + Description = request.TenantDescription, + UserIdentifier = userGuids[0], + FeatureKeys = featureKeys.Count > 0 ? string.Join(',', featureKeys) : null + }); + + foreach (var userGuid in userGuids.Skip(1)) + { + await TenantAppService.AssignManagerAsync(new TenantAssignManagerDto + { + TenantId = tenantDto.Id, + UserIdentifier = userGuid + }); + } + + if (ApplicationProvider != null) + await ApplicationProvider.CloseApplicationAsync(id); + } + + private async Task> RunValidationStepsAsync(OnboardingRequestDto request) + { + var issues = new List(); + foreach (var step in _validationSteps.OrderBy(s => s.Order)) + { + var stepResult = await step.ValidateAsync(request); + if (!stepResult.IsValid && stepResult.Issue is not null) + issues.Add($"[{step.StepName}] {stepResult.Issue}"); + } + return issues; + } + + private async Task ResolveFieldMappings(OnboardingRequestDto request, + string? tenantNameKey = null, string? superUsersKey = null, + string? branchKey = null, string? featuresKey = null, + string? ministryKey = null, string? programAreaKey = null) + { + var saved = await ReadTenantMappingAsync(); + tenantNameKey ??= saved.TenantNameFieldKey; + superUsersKey ??= saved.SuperUsersFieldKey; + branchKey ??= saved.BranchFieldKey; + featuresKey ??= saved.FeaturesFieldKey; + ministryKey ??= saved.MinistryFieldKey; + programAreaKey ??= saved.ProgramAreaFieldKey; + + if (!string.IsNullOrEmpty(tenantNameKey) && request.Fields.TryGetValue(tenantNameKey, out var tenantNameVal) && tenantNameVal is not null) + request.TenantName = tenantNameVal.ToString()!; + if (!string.IsNullOrEmpty(superUsersKey) && request.Fields.TryGetValue(superUsersKey, out var superUsersVal) && superUsersVal is not null) + request.SuperUsers = superUsersVal.ToString()!; + if (!string.IsNullOrEmpty(branchKey) && request.Fields.TryGetValue(branchKey, out var branchVal) && branchVal is not null) + request.Branch = branchVal.ToString()!; + if (!string.IsNullOrEmpty(featuresKey) && request.Fields.TryGetValue(featuresKey, out var featuresVal) && featuresVal is not null) + request.Features = featuresVal.ToString()!; + if (!string.IsNullOrEmpty(ministryKey) && request.Fields.TryGetValue(ministryKey, out var ministryVal) && ministryVal is not null) + request.Ministry = ministryVal.ToString()!; + if (!string.IsNullOrEmpty(programAreaKey) && request.Fields.TryGetValue(programAreaKey, out var programAreaVal) && programAreaVal is not null) + request.ProgramAreaName = programAreaVal.ToString()!; + } + + private async Task SaveFieldMappingAsync(string? tenantNameKey, string? superUsersKey, string? branchKey, string? featuresKey, string? ministryKey, string? programAreaKey) + { + var userId = CurrentUser.Id?.ToString(); + if (string.IsNullOrEmpty(userId)) return; + await _settingManager.SetAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, tenantNameKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, superUsersKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.BranchFieldKey, branchKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, featuresKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.MinistryFieldKey, ministryKey, UserProvider, userId); + await _settingManager.SetAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, programAreaKey, UserProvider, userId); + } + + private async Task ReadTenantMappingAsync() + { + var userId = CurrentUser.Id?.ToString(); + string? tenantNameKey = null, superUsersKey = null, branchKey = null, featuresKey = null, ministryKey = null, programAreaKey = null; + + if (!string.IsNullOrEmpty(userId)) + { + tenantNameKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, UserProvider, userId); + superUsersKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey, UserProvider, userId); + branchKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.BranchFieldKey, UserProvider, userId); + featuresKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.FeaturesFieldKey, UserProvider, userId); + ministryKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.MinistryFieldKey, UserProvider, userId); + programAreaKey = await _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey, UserProvider, userId); + } + + tenantNameKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.TenantNameFieldKey); + superUsersKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.SuperUsersFieldKey); + branchKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.BranchFieldKey); + featuresKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.FeaturesFieldKey); + ministryKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.MinistryFieldKey); + programAreaKey ??= await _settingManager.GetOrNullGlobalAsync(OnboardingColumnConfigSettings.ProgramAreaFieldKey); + + return new OnboardingColumnSchemaDto + { + TenantNameFieldKey = tenantNameKey, + SuperUsersFieldKey = superUsersKey, + BranchFieldKey = branchKey, + FeaturesFieldKey = featuresKey, + MinistryFieldKey = ministryKey, + ProgramAreaFieldKey = programAreaKey + }; + } + + private static List GetStaticColumnFilters(List? filters, Func isProviderColumn) => + (filters ?? []) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && isProviderColumn(cf.Name)) + .ToList(); + + private static List GetDynamicColumnFilters(List? filters, Func isProviderColumn) => + (filters ?? []) + .Where(cf => !string.IsNullOrWhiteSpace(cf.Value) && !isProviderColumn(cf.Name)) + .ToList(); + + private static bool IsStaticColumn(string name) => + name is "submissionNumber" or "status" or "category" or "submissionDate"; + + private static (string? Field, bool Descending) ParseSorting(string? sorting) + { + if (string.IsNullOrWhiteSpace(sorting)) return (null, false); + var parts = sorting.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var descending = parts.Length > 1 && parts[1].Equals("DESC", StringComparison.OrdinalIgnoreCase); + + // Dynamic columns use a "fields." dot-path on the client so DataTables can + // resolve the cell value; strip that prefix to get back the raw worksheet field key. + var field = parts[0]; + if (field.StartsWith("fields.", StringComparison.OrdinalIgnoreCase)) + field = field["fields.".Length..]; + + return (field, descending); + } + + private static string? GetWorksheetFieldValue( + IReadOnlyList instances, string fieldKey) + { + foreach (var wi in instances) + { + if (string.IsNullOrWhiteSpace(wi.CurrentValue) || wi.CurrentValue == "{}") continue; + try + { + var parsed = JsonSerializer.Deserialize(wi.CurrentValue); + var match = parsed?.Values?.FirstOrDefault(fv => + fv.Key.Equals(fieldKey, StringComparison.OrdinalIgnoreCase)); + if (match != null) return match.Value; + } + catch + { + // Malformed JSONB — skip silently + } + } + return null; + } + + private static bool MatchesGlobalInWorksheet( + IReadOnlyList instances, string filter) + { + var lowerFilter = filter.ToLowerInvariant(); + return instances.Any(wi => + { + if (string.IsNullOrWhiteSpace(wi.CurrentValue) || wi.CurrentValue == "{}") return false; + try + { + var parsed = JsonSerializer.Deserialize(wi.CurrentValue); + return parsed?.Values?.Any(fv => + fv.Value?.ToLowerInvariant().Contains(lowerFilter) ?? false) ?? false; + } + catch { return false; } + }); + } + + private static bool MatchesColumnInWorksheet( + IReadOnlyList instances, string fieldKey, string value) + { + var lowerValue = value.ToLowerInvariant(); + return instances.Any(wi => + { + if (string.IsNullOrWhiteSpace(wi.CurrentValue) || wi.CurrentValue == "{}") return false; + try + { + var parsed = JsonSerializer.Deserialize(wi.CurrentValue); + return parsed?.Values?.Any(fv => + fv.Key.Equals(fieldKey, StringComparison.OrdinalIgnoreCase) && + (fv.Value?.ToLowerInvariant().Contains(lowerValue) ?? false)) ?? false; + } + catch { return false; } + }); + } + + private static OnboardingRequestDto MapToDto( + OnboardingApplicationRecord app, + IEnumerable worksheetInstances, + OnboardingColumnSchemaDto mapping) + { + var dto = new OnboardingRequestDto + { + Id = app.Id, + SubmissionNumber = app.ReferenceNo, + SubmissionDate = app.SubmissionDate, + Status = app.Status, + Category = app.Category + }; + + foreach (var (key, value) in app.CoreFieldValues) + dto.Fields[key] = value; + + foreach (var instance in worksheetInstances) + { + if (string.IsNullOrWhiteSpace(instance.CurrentValue) || instance.CurrentValue == "{}") continue; + + try + { + var parsed = JsonSerializer.Deserialize(instance.CurrentValue); + if (parsed?.Values == null) continue; + + foreach (var fv in parsed.Values) + { + dto.Fields[fv.Key] = fv.Value; + + switch (fv.Key.ToLowerInvariant().Replace("-", "").Replace("_", "").Replace(" ", "")) + { + case "tenantdescription": case "description": dto.TenantDescription = fv.Value; break; + case "programareaname": case "programarea": dto.ProgramAreaName = fv.Value; break; + case "programareadescription": dto.ProgramAreaDescription = fv.Value; break; + case "contacts": dto.Contacts = fv.Value; break; + case "features": dto.Features = fv.Value; break; + case "executivedirector": dto.ExecutiveDirector = fv.Value; break; + case "branch": dto.Branch = fv.Value; break; + case "ministry": dto.Ministry = fv.Value; break; + } + } + } + catch + { + // Malformed JSONB — skip silently + } + } + + if (!string.IsNullOrEmpty(mapping.TenantNameFieldKey) && dto.Fields.TryGetValue(mapping.TenantNameFieldKey, out var tn) && tn != null) + dto.TenantName = tn.ToString()!; + if (!string.IsNullOrEmpty(mapping.SuperUsersFieldKey) && dto.Fields.TryGetValue(mapping.SuperUsersFieldKey, out var su) && su != null) + dto.SuperUsers = su.ToString()!; + if (!string.IsNullOrEmpty(mapping.BranchFieldKey) && dto.Fields.TryGetValue(mapping.BranchFieldKey, out var br) && br != null) + dto.Branch = br.ToString()!; + if (!string.IsNullOrEmpty(mapping.FeaturesFieldKey) && dto.Fields.TryGetValue(mapping.FeaturesFieldKey, out var ft) && ft != null) + dto.Features = ft.ToString()!; + if (!string.IsNullOrEmpty(mapping.MinistryFieldKey) && dto.Fields.TryGetValue(mapping.MinistryFieldKey, out var mn) && mn != null) + dto.Ministry = mn.ToString()!; + if (!string.IsNullOrEmpty(mapping.ProgramAreaFieldKey) && dto.Fields.TryGetValue(mapping.ProgramAreaFieldKey, out var pa) && pa != null) + dto.ProgramAreaName = pa.ToString()!; + + return dto; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/PlainConnectionStringDetector.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/PlainConnectionStringDetector.cs new file mode 100644 index 0000000000..63a42d0375 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/PlainConnectionStringDetector.cs @@ -0,0 +1,19 @@ +using System.Text.RegularExpressions; + +namespace Unity.TenantManagement.Application +{ + internal static partial class PlainConnectionStringDetector + { + // Require at least 2 keyword hits - a single hit (e.g. "Pwd=" or "Uid=") could + // coincidentally occur at the tail of valid base64 ciphertext, right before the + // padding '='. Real connection strings always carry several key=value pairs. + private const int MinKeywordMatches = 2; + + public static bool LooksLikePlainConnectionString(string value) + => ConnectionStringKeywordPattern().Matches(value).Count >= MinKeywordMatches; + + [GeneratedRegex(@"(Host|Server|Port|Database|Initial Catalog|Username|User Id|Uid|Pwd|Password|Data Source)\s*=", + RegexOptions.IgnoreCase)] + private static partial Regex ConnectionStringKeywordPattern(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Properties/AssemblyInfo.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..145fb8527b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Properties/AssemblyInfo.cs @@ -0,0 +1,2 @@ +using System.Runtime.CompilerServices; +[assembly:InternalsVisibleToAttribute("Unity.TenantManagement.Application.Tests")] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs index 4d1e1b01b2..3e5bed3287 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantAppService.cs @@ -1,6 +1,8 @@ -using System; +#nullable enable +using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Unity.TenantManagement.Abstractions; @@ -11,13 +13,15 @@ using Volo.Abp.Data; using Volo.Abp.EventBus.Local; using Volo.Abp.MultiTenancy; +using Volo.Abp.Security.Encryption; using Volo.Abp.TenantManagement; using Volo.Abp.Uow; using Volo.Abp.DependencyInjection; +using Volo.Abp.Identity; namespace Unity.TenantManagement; -[Authorize(TenantManagementPermissions.Tenants.Default)] +[Authorize(TenantManagementPermissions.Policies.TenantsOrITOps)] [ExposeServices(typeof(ITenantAppService), typeof(TenantAppService))] public class TenantAppService( ICurrentTenant currentTenant, @@ -25,8 +29,11 @@ public class TenantAppService( ITenantManager tenantManager, ILocalEventBus localEventBus, IUnitOfWorkManager unitOfWorkManager, - ITenantConnectionStringBuilder tenantConnectionStringBuilder) : TenantManagementAppServiceBase, ITenantAppService + ITenantConnectionStringBuilder tenantConnectionStringBuilder, + IStringEncryptionService stringEncryptionService) : TenantManagementAppServiceBase, ITenantAppService { + private IIdentityUserRepository IdentityUserRepository => LazyServiceProvider.LazyGetRequiredService(); + private const string ExtraPropDivision = "Division"; private const string ExtraPropBranch = "Branch"; private const string ExtraPropDescription = "Description"; @@ -84,10 +91,11 @@ public virtual async Task> GetListAsync(GetTenantsInpu } // In-memory path: needed when filtering on ExtraProperties or sorting on ExtraProperties + // Keep native name filtering in SQL and only layer ExtraProperties matching on top. var dbSorting = dbSortFields.Contains(sortField) ? input.Sorting : nameof(Tenant.Name); - var allTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, null); + var filteredTenants = await tenantRepository.GetListAsync(dbSorting, int.MaxValue, 0, input.Filter); - IEnumerable result = allTenants; + IEnumerable result = filteredTenants; // Apply ExtraProperties filter if (hasFilter) @@ -131,21 +139,36 @@ private static string GetExtraPropertyValue(Tenant tenant, string key) return entry.Value?.ToString() ?? string.Empty; } - [Authorize(TenantManagementPermissions.Tenants.Create)] + [Authorize(TenantManagementPermissions.Policies.TenantsCreateOrITOps)] public virtual async Task CreateAsync(TenantCreateDto input) { - Tenant tenant = null; + Tenant? tenant = null; using (var uow = unitOfWorkManager.Begin(true, false)) - { + { tenant = await tenantManager.CreateAsync(input.Name); + var credentials = await tenantConnectionStringBuilder.GenerateCredentialsAsync(); + + var plainConnectionString = tenantConnectionStringBuilder.Build(tenant.Name, credentials); + var encryptedConnectionString = stringEncryptionService.Encrypt(plainConnectionString); + tenant.ConnectionStrings .Add(new TenantConnectionString(tenant.Id, UnityTenantManagementConsts.TenantConnectionStringName, - tenantConnectionStringBuilder.Build(tenant.Name))); + encryptedConnectionString)); + + var readOnlyCredentials = tenantConnectionStringBuilder.GenerateReadOnlyCredentials(credentials); + var plainReadOnlyConnectionString = tenantConnectionStringBuilder.Build(tenant.Name, readOnlyCredentials); + var encryptedReadOnlyConnectionString = stringEncryptionService.Encrypt(plainReadOnlyConnectionString); + + tenant.ConnectionStrings + .Add(new TenantConnectionString(tenant.Id, + UnityTenantManagementConsts.TenantReadOnlyConnectionStringName, + encryptedReadOnlyConnectionString)); // Set ExtraProperties from input + tenant.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey] = credentials.DbName; tenant.ExtraProperties[ExtraPropDivision] = input.Division ?? string.Empty; tenant.ExtraProperties[ExtraPropBranch] = input.Branch ?? string.Empty; tenant.ExtraProperties[ExtraPropDescription] = input.Description ?? string.Empty; @@ -164,7 +187,8 @@ await localEventBus.PublishAsync( Name = tenant.Name, Properties = { - { "UserIdentifier", input.UserIdentifier } + { "UserIdentifier", input.UserIdentifier }, + { "FeatureKeys", input.FeatureKeys ?? string.Empty } } } ); @@ -172,7 +196,7 @@ await localEventBus.PublishAsync( return ObjectMapper.Map(tenant); } - [Authorize(TenantManagementPermissions.Tenants.Update)] + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] public virtual async Task UpdateAsync(Guid id, TenantUpdateDto input) { var tenant = await tenantRepository.GetAsync(id); @@ -204,35 +228,12 @@ public virtual async Task DeleteAsync(Guid id) await tenantRepository.DeleteAsync(tenant); } - [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] - public virtual async Task GetDefaultConnectionStringAsync(Guid id) - { - var tenant = await tenantRepository.GetAsync(id); - return tenant?.FindDefaultConnectionString(); - } - - [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] - public virtual async Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString) - { - var tenant = await tenantRepository.GetAsync(id); - tenant.SetDefaultConnectionString(defaultConnectionString); - await tenantRepository.UpdateAsync(tenant); - } - - [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] - public virtual async Task DeleteDefaultConnectionStringAsync(Guid id) - { - var tenant = await tenantRepository.GetAsync(id); - tenant.RemoveDefaultConnectionString(); - await tenantRepository.UpdateAsync(tenant); - } - - [RemoteService(false)] + [RemoteService(false)] [AllowAnonymous] public async Task GetCurrentTenantCasClientCodeAsync(Guid tenantId) { var tenant = tenantId != Guid.Empty ? await tenantRepository.GetAsync(tenantId) : null; - return tenant?.ExtraProperties.TryGetValue("CasClientCode", out var value) == true ? value?.ToString() : null; + return (tenant?.ExtraProperties.TryGetValue("CasClientCode", out var value) == true ? value?.ToString() : null)!; } public async Task GetCurrentTenantName() @@ -242,6 +243,7 @@ public async Task GetCurrentTenantName() return tenant?.Name ?? string.Empty; } + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] public async Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) { await localEventBus.PublishAsync( @@ -252,4 +254,77 @@ await localEventBus.PublishAsync( } ); } + + [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] + public async Task GetConnectionStringsAsync(Guid id) + { + var tenant = await tenantRepository.GetAsync(id, includeDetails: true); + return new TenantConnectionStringsDto + { + TenantConnectionString = TryDecryptConnectionString( + tenant.FindConnectionString(UnityTenantManagementConsts.TenantConnectionStringName)), + ReadOnlyConnectionString = TryDecryptConnectionString( + tenant.FindConnectionString(UnityTenantManagementConsts.TenantReadOnlyConnectionStringName)) + }; + } + + [Authorize(TenantManagementPermissions.Tenants.ManageConnectionStrings)] + public async Task UpdateConnectionStringsAsync(Guid id, TenantConnectionStringsDto input) + { + var tenant = await tenantRepository.GetAsync(id, includeDetails: true); + + if (!string.IsNullOrWhiteSpace(input.TenantConnectionString)) + { + tenant.SetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName, + stringEncryptionService.Encrypt(input.TenantConnectionString)); + } + + if (!string.IsNullOrWhiteSpace(input.ReadOnlyConnectionString)) + { + tenant.SetConnectionString(UnityTenantManagementConsts.TenantReadOnlyConnectionStringName, + stringEncryptionService.Encrypt(input.ReadOnlyConnectionString)); + } + + await tenantRepository.UpdateAsync(tenant); + } + + [Authorize(TenantManagementPermissions.Policies.TenantsUpdateOrITOps)] + public async Task> GetManagersAsync(Guid id) + { + using (currentTenant.Change(id)) + { + var users = await IdentityUserRepository.GetListByNormalizedRoleNameAsync("PROGRAM_MANAGER"); + return users.Select(u => + { + var displayName = $"{u.Name} {u.Surname}".Trim(); + return new TenantManagerDto + { + DisplayName = string.IsNullOrEmpty(displayName) ? u.UserName : displayName, + Email = u.Email ?? string.Empty + }; + }).ToList(); + } + } + + private string? TryDecryptConnectionString(string? rawValue) + { + if (rawValue == null) return null; + if (PlainConnectionStringDetector.LooksLikePlainConnectionString(rawValue)) return rawValue; + + try + { + return stringEncryptionService.Decrypt(rawValue); + } + catch (FormatException) + { + // Not valid base64, so it can't be ciphertext - it's plain text. + return rawValue; + } + catch (CryptographicException) + { + // Valid base64 but failed to decrypt (wrong passphrase/corrupted ciphertext) - + // fall back to the raw value rather than breaking the admin UI. + return rawValue; + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs index 1e83ae5238..7a0058bccf 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/TenantConnectionStringBuilder.cs @@ -1,33 +1,120 @@ -using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; using Unity.TenantManagement.Application.Contracts; using Volo.Abp; using Volo.Abp.Application.Services; +using Volo.Abp.TenantManagement; namespace Unity.TenantManagement.Application { [RemoteService(false)] public class TenantConnectionStringBuilder : ApplicationService, ITenantConnectionStringBuilder { + private static readonly char[] Letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".ToCharArray(); + private static readonly char[] Digits = "0123456789".ToCharArray(); + + // Deliberately excludes quote/backslash characters — the password is interpolated into + // a single-quoted SQL literal by EntityFrameworkCoreGrantManagerDbSchemaMigrator, so + // restricting the charset here removes that injection surface at the source rather than + // relying solely on escaping at the consuming end. + private static readonly char[] PasswordAlphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".ToCharArray(); + private readonly IConfiguration _configuration; + private readonly ITenantRepository _tenantRepository; - public TenantConnectionStringBuilder(IConfiguration configuration) + public TenantConnectionStringBuilder(IConfiguration configuration, ITenantRepository tenantRepository) { _configuration = configuration; + _tenantRepository = tenantRepository; + } + + public string Build(string tenantName, TenantDbCredentials credentials) + { + var baseConnectionString = _configuration.GetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName) + ?? throw new UserFriendlyException("Connection string configuration error"); + + return ReplaceKeyValues(baseConnectionString, new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Database"] = credentials.DbName, + ["Username"] = credentials.Username, + ["Password"] = credentials.Password + }); + } + + // Replaces connection string values by key (case-insensitive) while preserving + // the original key casing from the template (e.g. "Host" stays "Host", not "host"). + private static string ReplaceKeyValues(string connectionString, Dictionary replacements) + { + var parts = connectionString.Split(';'); + for (int i = 0; i < parts.Length; i++) + { + var eq = parts[i].IndexOf('='); + if (eq > 0 && replacements.TryGetValue(parts[i][..eq].Trim(), out var newValue)) + { + parts[i] = $"{parts[i][..eq]}={newValue}"; + } + } + return string.Join(";", parts); } - public string Build(string tenantName) + public async Task GenerateCredentialsAsync() { - var connectionString = _configuration.GetConnectionString(UnityTenantManagementConsts.TenantConnectionStringName); + var allTenants = await _tenantRepository.GetListAsync(nameof(Tenant.Name), int.MaxValue, 0, null, includeDetails: true); - return connectionString == null - ? throw new UserFriendlyException("Connection string configuration error") - : connectionString - .Replace(UnityTenantManagementConsts.TenantConnectionStringTenantDb, PrepTenantName(tenantName)); + var existingDbNames = allTenants + .Where(t => t.ExtraProperties.ContainsKey(UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey)) + .Select(t => t.ExtraProperties[UnityTenantManagementConsts.TenantLicencePlateExtraPropertyKey]?.ToString() ?? "") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + string dbName; + do + { + dbName = GenerateDbName(); + } + while (existingDbNames.Contains(dbName)); + + return new TenantDbCredentials(dbName, dbName, GeneratePassword()); + } + + public TenantDbCredentials GenerateReadOnlyCredentials(TenantDbCredentials credentials) + { + return new TenantDbCredentials(credentials.DbName, $"{credentials.Username}_readonly", GeneratePassword()); + } + + private static string GenerateDbName() + { + // Format: T_XXX999 where X is A-Z and 9 is 0-9, e.g. T_ABC123 + Span chars = + [ + 'T', + '_', + Letters[RandomNumberGenerator.GetInt32(Letters.Length)], + Letters[RandomNumberGenerator.GetInt32(Letters.Length)], + Letters[RandomNumberGenerator.GetInt32(Letters.Length)], + Digits[RandomNumberGenerator.GetInt32(Digits.Length)], + Digits[RandomNumberGenerator.GetInt32(Digits.Length)], + Digits[RandomNumberGenerator.GetInt32(Digits.Length)] + ]; + return new string(chars); } - private static string PrepTenantName(string tenantName) + private static string GeneratePassword() { - return tenantName.Trim().Replace(" ", ""); + // 24 cryptographically random characters (mixed-case letters + digits) — generated + // via RandomNumberGenerator (CSPRNG), not the non-cryptographic Random.Shared, since + // this protects a live PostgreSQL login role's credentials. + const int length = 24; + Span chars = new char[length]; + for (var i = 0; i < length; i++) + { + chars[i] = PasswordAlphabet[RandomNumberGenerator.GetInt32(PasswordAlphabet.Length)]; + } + return new string(chars); } } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj index ece4db4b9b..37aa38e406 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Unity.TenantManagement.Application.csproj @@ -15,12 +15,16 @@ + +
+ + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs index 98cc1dbb0c..5c4344e95a 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementApplicationModule.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Unity.Flex; using Volo.Abp.Mapperly; using Volo.Abp.Modularity; using Volo.Abp.TenantManagement; @@ -9,7 +10,8 @@ namespace Unity.TenantManagement typeof(AbpTenantManagementDomainModule), typeof(UnityTenantManagementApplicationContractsModule), typeof(AbpTenantManagementApplicationModule), - typeof(AbpMapperlyModule) + typeof(AbpMapperlyModule), + typeof(FlexApplicationContractsModule) )] public class UnityTenantManagementApplicationModule : AbpModule { diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs index 0c6d6833fe..0e2275ea37 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementConsts.cs @@ -4,6 +4,8 @@ public static class UnityTenantManagementConsts { public const string TenantConnectionStringName = "Tenant"; - public const string TenantConnectionStringTenantDb = "UnityGrantTenant"; + public const string TenantReadOnlyConnectionStringName = "Tenant_Readonly"; + + public const string TenantLicencePlateExtraPropertyKey = "LicencePlate"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs index 2d806371cf..c0af541474 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/UnityTenantManagementMapperlyProfile.cs @@ -20,6 +20,7 @@ public override void Map(Tenant source, TenantDto destination) destination.Name = source.Name; destination.ConcurrencyStamp = source.ConcurrencyStamp; destination.CasClientCode = GetExtraProperty(source, "CasClientCode") ?? string.Empty; + destination.LicencePlate = GetExtraProperty(source, "LicencePlate") ?? string.Empty; destination.Division = GetExtraProperty(source, "Division") ?? string.Empty; destination.Branch = GetExtraProperty(source, "Branch") ?? string.Empty; destination.Description = GetExtraProperty(source, "Description") ?? string.Empty; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs new file mode 100644 index 0000000000..7db1572c3c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/SuperUsersValidationStep.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Unity.Flex.Worksheets.Values; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.TenantManagement.Validation; + +[RemoteService(false)] +[ExposeServices(typeof(IOnboardingValidationStep))] +public class SuperUsersValidationStep(IOnboardingUserLookup userLookup) + : IOnboardingValidationStep, ITransientDependency +{ + public int Order => 20; + public string StepName => "Super Users"; + + public async Task ValidateAsync(OnboardingRequestDto request) + { + var emails = ParseEmails(request.SuperUsers); + if (emails.Length == 0) + return OnboardingValidationStepResult.Failure("No super user email addresses specified."); + + foreach (var email in emails) + { + var guid = await userLookup.FindUserGuidByEmailAsync(email); + if (!string.IsNullOrWhiteSpace(guid)) + return OnboardingValidationStepResult.Success(); + } + + return OnboardingValidationStepResult.Failure( + "None of the specified super user email addresses could be found in the directory."); + } + + internal static string[] ParseEmails(string superUsers) + { + var dataGridEmails = ParseDataGridEmails(superUsers); + if (dataGridEmails.Length > 0) + return dataGridEmails; + + return [.. superUsers.Split([',', ';', '|'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Where(e => e.Contains('@'))]; + } + + // Formio/CHEFS "Super Users" fields are submitted as a DataGrid: one row per super user, with + // columns such as name/email/title. The email column's key varies per worksheet (e.g. + // "s03_SuperUserEmail"), so it's matched by name rather than a fixed key. + private static string[] ParseDataGridEmails(string superUsers) + { + DataGridRowsValue grid; + try + { + grid = JsonSerializer.Deserialize(superUsers); + } + catch (JsonException) + { + return []; + } + + if (grid?.Rows is not { Count: > 0 }) return []; + + return [.. grid.Rows + .Select(r => r.Cells.FirstOrDefault(c => c.Key.Contains("email", StringComparison.OrdinalIgnoreCase))?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v) && v.Contains('@')) + .Select(v => v!.Trim())]; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/TenantNameUniquenessStep.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/TenantNameUniquenessStep.cs new file mode 100644 index 0000000000..3717b1a57b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application/Validation/TenantNameUniquenessStep.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; +using Volo.Abp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.TenantManagement; + +namespace Unity.TenantManagement.Validation; + +[RemoteService(false)] +[ExposeServices(typeof(IOnboardingValidationStep))] +public class TenantNameUniquenessStep(ITenantRepository tenantRepository) + : IOnboardingValidationStep, ITransientDependency +{ + public int Order => 10; + public string StepName => "Tenant Name"; + + public async Task ValidateAsync(OnboardingRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.TenantName)) + return OnboardingValidationStepResult.Failure("Tenant name is required."); + + // FindByNameAsync matches against NormalizedName (stored as ToUpper()) + var existing = await tenantRepository.FindByNameAsync(request.TenantName.ToUpper()); + return existing is not null + ? OnboardingValidationStepResult.Failure($"A tenant named '{request.TenantName}' already exists.") + : OnboardingValidationStepResult.Success(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.Generated.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.Generated.cs index 8afbfed3f6..187ad02c74 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.Generated.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.Generated.cs @@ -53,28 +53,4 @@ public virtual async Task DeleteAsync(Guid id) }); } - public virtual async Task GetDefaultConnectionStringAsync(Guid id) - { - return await RequestAsync(nameof(GetDefaultConnectionStringAsync), new ClientProxyRequestTypeValue - { - { typeof(Guid), id } - }); - } - - public virtual async Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString) - { - await RequestAsync(nameof(UpdateDefaultConnectionStringAsync), new ClientProxyRequestTypeValue - { - { typeof(Guid), id }, - { typeof(string), defaultConnectionString } - }); - } - - public virtual async Task DeleteDefaultConnectionStringAsync(Guid id) - { - await RequestAsync(nameof(DeleteDefaultConnectionStringAsync), new ClientProxyRequestTypeValue - { - { typeof(Guid), id } - }); - } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs index c834e39e03..bdd9379c6d 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/TenantClientProxy.cs @@ -1,5 +1,7 @@ // This file is part of TenantClientProxy, you can customize it here // ReSharper disable once CheckNamespace +using System; +using System.Collections.Generic; using System.Threading.Tasks; using Volo.Abp.Http.Client.ClientProxying; @@ -7,6 +9,14 @@ namespace Unity.TenantManagement; public partial class TenantClientProxy { + public virtual async Task> GetManagersAsync(Guid id) + { + return await RequestAsync>(nameof(GetManagersAsync), new ClientProxyRequestTypeValue + { + { typeof(Guid), id } + }); + } + public virtual async Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) { await RequestAsync(nameof(AssignManagerAsync), new ClientProxyRequestTypeValue @@ -14,4 +24,21 @@ public virtual async Task AssignManagerAsync(TenantAssignManagerDto managerAssig { typeof(TenantAssignManagerDto), managerAssignment } }); } + + public virtual async Task GetConnectionStringsAsync(Guid id) + { + return await RequestAsync(nameof(GetConnectionStringsAsync), new ClientProxyRequestTypeValue + { + { typeof(Guid), id } + }); + } + + public virtual async Task UpdateConnectionStringsAsync(Guid id, TenantConnectionStringsDto input) + { + await RequestAsync(nameof(UpdateConnectionStringsAsync), new ClientProxyRequestTypeValue + { + { typeof(Guid), id }, + { typeof(TenantConnectionStringsDto), input } + }); + } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/multi-tenancy-generate-proxy.json b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/multi-tenancy-generate-proxy.json index 44e4a7eeb5..97c9abf536 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/multi-tenancy-generate-proxy.json +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/ClientProxies/multi-tenancy-generate-proxy.json @@ -16,65 +16,6 @@ "type": "Unity.TenantManagement.ITenantAppService", "name": "ITenantAppService", "methods": [ - { - "name": "GetDefaultConnectionStringAsync", - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "returnValue": { - "type": "System.String", - "typeSimple": "string" - } - }, - { - "name": "UpdateDefaultConnectionStringAsync", - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - }, - { - "name": "defaultConnectionString", - "typeAsString": "System.String, System.Private.CoreLib", - "type": "System.String", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - } - }, - { - "name": "DeleteDefaultConnectionStringAsync", - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - } - }, { "name": "GetAsync", "parametersOnMethod": [ @@ -412,137 +353,6 @@ }, "allowAnonymous": null, "implementFrom": "Unity.Application.Services.IDeleteAppService" - }, - "GetDefaultConnectionStringAsyncById": { - "uniqueName": "GetDefaultConnectionStringAsyncById", - "name": "GetDefaultConnectionStringAsync", - "httpMethod": "GET", - "url": "api/multi-tenancy/tenants/{id}/default-connection-string", - "supportedVersions": [], - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "parameters": [ - { - "nameOnMethod": "id", - "name": "id", - "jsonName": null, - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": [], - "bindingSourceId": "Path", - "descriptorName": "" - } - ], - "returnValue": { - "type": "System.String", - "typeSimple": "string" - }, - "allowAnonymous": null, - "implementFrom": "Unity.TenantManagement.ITenantAppService" - }, - "UpdateDefaultConnectionStringAsyncByIdAndDefaultConnectionString": { - "uniqueName": "UpdateDefaultConnectionStringAsyncByIdAndDefaultConnectionString", - "name": "UpdateDefaultConnectionStringAsync", - "httpMethod": "PUT", - "url": "api/multi-tenancy/tenants/{id}/default-connection-string", - "supportedVersions": [], - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - }, - { - "name": "defaultConnectionString", - "typeAsString": "System.String, System.Private.CoreLib", - "type": "System.String", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "parameters": [ - { - "nameOnMethod": "id", - "name": "id", - "jsonName": null, - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": [], - "bindingSourceId": "Path", - "descriptorName": "" - }, - { - "nameOnMethod": "defaultConnectionString", - "name": "defaultConnectionString", - "jsonName": null, - "type": "System.String", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": null, - "bindingSourceId": "ModelBinding", - "descriptorName": "" - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - }, - "allowAnonymous": null, - "implementFrom": "Unity.TenantManagement.ITenantAppService" - }, - "DeleteDefaultConnectionStringAsyncById": { - "uniqueName": "DeleteDefaultConnectionStringAsyncById", - "name": "DeleteDefaultConnectionStringAsync", - "httpMethod": "DELETE", - "url": "api/multi-tenancy/tenants/{id}/default-connection-string", - "supportedVersions": [], - "parametersOnMethod": [ - { - "name": "id", - "typeAsString": "System.Guid, System.Private.CoreLib", - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null - } - ], - "parameters": [ - { - "nameOnMethod": "id", - "name": "id", - "jsonName": null, - "type": "System.Guid", - "typeSimple": "string", - "isOptional": false, - "defaultValue": null, - "constraintTypes": [], - "bindingSourceId": "Path", - "descriptorName": "" - } - ], - "returnValue": { - "type": "System.Void", - "typeSimple": "System.Void" - }, - "allowAnonymous": null, - "implementFrom": "Unity.TenantManagement.ITenantAppService" } } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs new file mode 100644 index 0000000000..e00ada72a8 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/OnboardingRequestController.cs @@ -0,0 +1,69 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.AspNetCore.Mvc; + +namespace Unity.TenantManagement; + +[Controller] +[RemoteService(Name = TenantManagementRemoteServiceConsts.RemoteServiceName)] +[Area(TenantManagementRemoteServiceConsts.ModuleName)] +[Route("api/onboarding-requests")] +public class OnboardingRequestController(IOnboardingRequestAppService onboardingRequestAppService) + : AbpControllerBase, IOnboardingRequestAppService +{ + protected IOnboardingRequestAppService OnboardingRequestAppService { get; } = onboardingRequestAppService; + + [HttpGet] + public virtual Task> GetListAsync([FromQuery] OnboardingListRequestDto input) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetListAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetListAsync(input); + } + + [HttpGet("{id}")] + public virtual Task GetAsync(Guid id) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetAsync(id); + } + + [HttpGet("{id}/validate")] + public virtual Task ValidateAsync( + Guid id, + [FromQuery] string? tenantNameFieldKey = null, + [FromQuery] string? superUsersFieldKey = null, + [FromQuery] string? branchFieldKey = null, + [FromQuery] string? featuresFieldKey = null, + [FromQuery] string? ministryFieldKey = null, + [FromQuery] string? programAreaFieldKey = null) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->ValidateAsync: ModelState Invalid"); + return OnboardingRequestAppService.ValidateAsync(id, tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey); + } + + [HttpPost("{id}/create-tenant")] + public virtual Task CreateTenantAsync(Guid id, [FromBody] CreateTenantInputDto? input = null) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->CreateTenantAsync: ModelState Invalid"); + return OnboardingRequestAppService.CreateTenantAsync(id, input); + } + + [HttpGet("column-schema")] + public virtual Task GetColumnSchemaAsync([FromQuery] string? category = null) + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetColumnSchemaAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetColumnSchemaAsync(category); + } + + [HttpGet("categories")] + public virtual Task> GetAvailableCategoriesAsync() + { + if (!ModelState.IsValid) throw new UserFriendlyException("OnboardingRequestController->GetAvailableCategoriesAsync: ModelState Invalid"); + return OnboardingRequestAppService.GetAvailableCategoriesAsync(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs index f87e1706bf..e036ba383c 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/TenantController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp; @@ -69,46 +70,46 @@ public virtual Task DeleteAsync(Guid id) } [HttpGet] - [Route("{id}/default-connection-string")] - public virtual Task GetDefaultConnectionStringAsync(Guid id) + [Route("{id}/managers")] + public virtual Task> GetManagersAsync(Guid id) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->GetDefaultConnectionStringAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->GetManagersAsync: ModelState Invalid"); } - return TenantAppService.GetDefaultConnectionStringAsync(id); + return TenantAppService.GetManagersAsync(id); } [HttpPut] - [Route("{id}/default-connection-string")] - public virtual Task UpdateDefaultConnectionStringAsync(Guid id, string defaultConnectionString) + [Route("assign-manager")] + public virtual Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->UpdateDefaultConnectionStringAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->AssignManagerAsync: ModelState Invalid"); } - return TenantAppService.UpdateDefaultConnectionStringAsync(id, defaultConnectionString); + return TenantAppService.AssignManagerAsync(managerAssignment); } - [HttpDelete] - [Route("{id}/default-connection-string")] - public virtual Task DeleteDefaultConnectionStringAsync(Guid id) + [HttpGet] + [Route("{id}/connection-strings")] + public virtual Task GetConnectionStringsAsync(Guid id) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->DeleteDefaultConnectionStringAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->GetConnectionStringsAsync: ModelState Invalid"); } - return TenantAppService.DeleteDefaultConnectionStringAsync(id); + return TenantAppService.GetConnectionStringsAsync(id); } [HttpPut] - [Route("assign-manager")] - public virtual Task AssignManagerAsync(TenantAssignManagerDto managerAssignment) + [Route("{id}/connection-strings")] + public virtual Task UpdateConnectionStringsAsync(Guid id, [FromBody] TenantConnectionStringsDto input) { if (!ModelState.IsValid) { - throw new UserFriendlyException("TenantController->AssignManagerAsync: ModelState Invalid"); + throw new UserFriendlyException("TenantController->UpdateConnectionStringsAsync: ModelState Invalid"); } - return TenantAppService.AssignManagerAsync(managerAssignment); + return TenantAppService.UpdateConnectionStringsAsync(id, input); } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs index 007c2d6ab7..9c44cbbddd 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/TenantManagementMenuNames.cs @@ -7,4 +7,5 @@ public static class TenantManagementMenuNames public const string Tenants = GroupName + ".Tenants"; public const string Endpoints = GroupName + ".Endpoints"; public const string Reconciliation = GroupName + ".Reconciliation"; + public const string Onboarding = GroupName + ".Onboarding"; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs index 519a5fbeee..54c9e44dea 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Navigation/UnityTenantManagementWebMainMenuContributor.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Permissions; using Volo.Abp.TenantManagement.Localization; using Volo.Abp.UI.Navigation; using Volo.Abp.Authorization.Permissions; @@ -21,7 +22,10 @@ public virtual Task ConfigureMenuAsync(MenuConfigurationContext context) var tenantManagementMenuItem = new ApplicationMenuItem(TenantManagementMenuNames.GroupName, l["Menu:TenantManagement"], icon: "fa fa-users"); administrationMenu.AddItem(tenantManagementMenuItem); - tenantManagementMenuItem.AddItem(new ApplicationMenuItem(TenantManagementMenuNames.Tenants, l["Tenants"], url: "~/TenantManagement/Tenants").RequirePermissions(TenantManagementPermissions.Tenants.Default)); + tenantManagementMenuItem.AddItem( + new ApplicationMenuItem(TenantManagementMenuNames.Tenants, l["Tenants"], url: "~/TenantManagement/Tenants") + .RequirePermissions(TenantManagementPermissions.Tenants.Default, IdentityConsts.ITOperationsPermissionName) + ); return Task.CompletedTask; } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml new file mode 100644 index 0000000000..f396127cdf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml @@ -0,0 +1,103 @@ +@page +@using Microsoft.AspNetCore.Mvc.Localization +@using Unity.GrantManager.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@using Unity.TenantManagement +@using Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding +@model CreateTenantModalModel +@inject IHtmlLocalizer L +@{ + Layout = null; +} + + + + + + + + +
+ + @L["CreateTenantModal:Validating"] +
+
+ +
+
+ + @L["CreateTenantModal:CreatingSpinner"] +
+
+ @L["CreateTenantModal:CreatingWarningTitle"] + @L["CreateTenantModal:CreatingWarningBody"] +
+
+ +
+ + + + +
diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs new file mode 100644 index 0000000000..bf3d068f36 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/CreateTenantModal.cshtml.cs @@ -0,0 +1,25 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Unity.Modules.Shared.Permissions; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class CreateTenantModalModel(IOnboardingRequestAppService onboardingRequestAppService) + : OnboardingPageModel +{ + [BindProperty(SupportsGet = true)] + public Guid Id { get; set; } + + public OnboardingRequestDto? OnboardingRequest { get; set; } + + public virtual async Task OnGetAsync() + { + OnboardingRequest = await onboardingRequestAppService.GetAsync(Id); + if (OnboardingRequest == null) return NotFound(); + return Page(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml new file mode 100644 index 0000000000..f134a44543 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml @@ -0,0 +1,48 @@ +@page +@using Microsoft.AspNetCore.Mvc.Localization +@using Unity.GrantManager.Localization +@using Unity.TenantManagement.Web.Navigation +@using Volo.Abp.AspNetCore.Mvc.UI.Layout +@using Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding +@model IndexModel +@inject IHtmlLocalizer L +@inject IPageLayout PageLayout +@{ + PageLayout.Content.BreadCrumb.Add(L["Menu:Onboarding"].Value); + PageLayout.Content.MenuItemName = TenantManagementMenuNames.Onboarding; + ViewBag.PageTitle = L["Menu:Onboarding"].Value; +} +@section styles { + + +} +@section scripts { + + + + +} +
+
+
+ +
+
+ + +
+
+ + + +
+
+
+ +
diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml.cs new file mode 100644 index 0000000000..ce8af35a00 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.cshtml.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; +using Unity.Modules.Shared.Permissions; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; + +[Authorize(IdentityConsts.ITOperationsPolicyName)] +public class IndexModel : OnboardingPageModel +{ + public void OnGet() { } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js new file mode 100644 index 0000000000..eb7317d294 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/Index.js @@ -0,0 +1,622 @@ +(function () { + let l = abp.localization.getResource('GrantManager'); + let _onboardingRequestAppService = unity.tenantManagement.onboardingRequest; + let _dataTable = null; + let _selectedRow = null; + let _selectedCategory = 'Onboarding'; + + const CATEGORY_STORAGE_KEY = 'Onboarding_SelectedCategory'; + + // ─── Fixed columns (always present) ────────────────────────────────────── + + const FIXED_COLUMNS = [ + { + title: l('Onboarding:ColumnSubmissionNumber'), + data: 'submissionNumber', + name: 'submissionNumber', + index: 0, + render: function (data, type, row) { + if (type !== 'display') return data ?? ''; + return '' + _escapeHtml(data) + ''; + } + }, + { title: l('Onboarding:ColumnStatus'), data: 'status', name: 'status', index: 1 }, + { + title: l('Onboarding:ColumnSubmissionDate'), + data: 'submissionDate', + name: 'submissionDate', + index: 2, + render: function (data, type) { + return DateUtils.formatUtcDateToLocal(data, type); + } + }, + { title: l('Onboarding:ColumnCategory'), data: 'category', name: 'category', index: 3 } + ]; + + // ─── Field value renderers ──────────────────────────────────────────────── + + function _escapeHtml(str) { + return String(str) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + } + + function _formatCheckboxKey(key) { + return String(key) + .replaceAll(/([a-z])([A-Z])/g, '$1 $2') + .replaceAll(/\b\w/g, function (c) { return c.toUpperCase(); }); + } + + // Worksheet CheckboxGroup values are always serialized as [{key, value}, ...]. + function _extractCheckboxLabels(data) { + if (data === null || data === undefined || data === '') return []; + try { + const items = JSON.parse(data); + if (Array.isArray(items)) { + return items.filter(function (i) { return i?.value === true; }).map(function (i) { return i.key; }); + } + } catch { + // not a CheckboxGroup value + } + return []; + } + + function _renderCheckboxGroup(data, type) { + // No value submitted at all for this field — leave the cell blank, as opposed to + // data being present but resolving to zero checked labels (handled below). + if (data === null || data === undefined || data === '') return ''; + + const labels = _extractCheckboxLabels(data); + + if (type === 'sort' || type === 'filter' || type === 'type') { + return labels.join(', '); + } + + if (labels.length === 0) return ''; + + return labels.map(function (label) { + return '' + + _escapeHtml(_formatCheckboxKey(label)) + + ''; + }).join(''); + } + + // ─── Super Users DataGrid email extraction ─────────────────────────────── + + // Formio/CHEFS "Super Users" fields are submitted as a DataGrid: one row per super + // user, with columns such as name/email/title. The email column's key varies per + // worksheet (e.g. "s03_SuperUserEmail"), so it's matched by name rather than a fixed + // key — mirrors SuperUsersValidationStep.ParseEmails on the server. + // Returns null when `raw` isn't a DataGrid value at all (legacy plain-text field). + function _extractDataGridEmails(raw) { + if (!raw) return null; + let parsed; + try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; } + if (!parsed || !Array.isArray(parsed.rows)) return null; + + return parsed.rows + .map(function (row) { + const cell = (row.cells || []).find(function (c) { return /email/i.test(c.key || ''); }); + return cell ? String(cell.value || '').trim() : ''; + }) + .filter(function (v) { return v.includes('@'); }); + } + + // ─── DOM-based preview builders ────────────────────────────────────────── + // Used by _updateFieldPreview below, which injects these via DOM append rather than + // .html() — these build real elements with jQuery's .text() for any dynamic content + // (the worksheet field value), so there's no HTML-string sink for CodeQL/XSS scanners + // to flag, unlike the string-concatenation renderers above (which still serve the + // DataTables column-render path, where a string return is required). + + function _buildMutedPlaceholder() { + return $('').addClass('text-muted').text('—'); + } + + function _buildCheckboxBadgesPreview(data) { + const labels = _extractCheckboxLabels(data); + if (labels.length === 0) return _buildMutedPlaceholder(); + + return $(labels.map(function (label) { + return $('') + .addClass('badge rounded-pill bg-light text-dark border me-1 onboarding-checkbox-badge') + .text(_formatCheckboxKey(label)) + .get(0); + })); + } + + function _buildSuperUsersPreview(data) { + const emails = _extractDataGridEmails(data); + if (emails === null) { + return data ? $('').text(data) : _buildMutedPlaceholder(); + } + return emails.length ? $('').text(emails.join(', ')) : _buildMutedPlaceholder(); + } + + // ─── DataGrid cell renderer ─────────────────────────────────────────────── + + let _dataGridCache = {}; + let _dataGridCacheId = 0; + + function _renderDataGridIcon(data, columnTitle) { + if (!data) return ''; + + let parsed; + try { + parsed = typeof data === 'string' ? JSON.parse(data) : data; + } catch { + return ''; + } + if (!parsed || !Array.isArray(parsed.rows) || parsed.rows.length === 0) return ''; + + const cacheKey = 'dg-' + (_dataGridCacheId++); + _dataGridCache[cacheKey] = { grid: parsed, title: columnTitle }; + return ''; + } + + function _openDataGridModal(grid, title) { + const keys = []; + grid.rows.forEach(function (row) { + (row.cells || []).forEach(function (cell) { + if (!keys.includes(cell.key)) keys.push(cell.key); + }); + }); + + const headerHtml = keys.map(function (k) { return '' + _escapeHtml(_formatCheckboxKey(k)) + ''; }).join(''); + const rowsHtml = grid.rows.map(function (row) { + const cellsByKey = {}; + (row.cells || []).forEach(function (c) { cellsByKey[c.key] = c.value; }); + const tds = keys.map(function (k) { return '' + _escapeHtml(cellsByKey[k] ?? '') + ''; }).join(''); + return '' + tds + ''; + }).join(''); + + const modalHtml = ''; + + $('#onboardingDataGridModal').remove(); + $('body').append(modalHtml); + const modalEl = document.getElementById('onboardingDataGridModal'); + const modal = new bootstrap.Modal(modalEl); + modalEl.addEventListener('hidden.bs.modal', function () { $(modalEl).remove(); }); + modal.show(); + } + + $(document).on('click', '.onboarding-datagrid-btn', function (e) { + e.stopPropagation(); + const cached = _dataGridCache[$(this).data('datagrid-key')]; + if (cached) _openDataGridModal(cached.grid, cached.title); + }); + + // ─── Column builder ─────────────────────────────────────────────────────── + + function buildColumns(schema) { + let cols = FIXED_COLUMNS.map(function (c, i) { return { ...c, index: i }; }); + + if (schema?.columns) { + let offset = cols.length; + schema.columns.forEach(function (c, i) { + let col = { + title: c.label, + // Use a dot-path string (not a function) so DataTables/ABP can resolve + // the column's name for server-side sorting — a function data accessor + // breaks that resolution. + data: 'fields.' + c.key, + name: c.key, + defaultContent: '', + index: offset + i + }; + if (c.type === 'Date') { + col.render = function (data, type) { return DateUtils.formatUtcDateToLocal(data, type); }; + } else if (c.type === 'CheckboxGroup') { + col.render = _renderCheckboxGroup; + } else if (c.type === 'DataGrid') { + col.render = function (data, type) { return type === 'display' ? _renderDataGridIcon(data, c.label) : ''; }; + } + cols.push(col); + }); + } + + return cols; + } + + // ─── DataTable init ─────────────────────────────────────────────────────── + + function initTable(schema) { + if (_dataTable) { + _dataTable.off('select deselect'); + _dataTable.destroy(); + $('#OnboardingRequestsTable').empty(); + _dataTable = null; + _selectedRow = null; + manageActionButtons(); + } + + let listColumns = buildColumns(schema); + + _dataTable = initializeDataTable({ + dt: $('#OnboardingRequestsTable'), + listColumns: listColumns, + defaultSortColumn: 0, + dataEndpoint: _onboardingRequestAppService.getList, + data: function (requestData) { + const extras = { category: _selectedCategory }; + + const globalSearch = requestData?.search?.value; + if (globalSearch) extras.filter = globalSearch; + + // Column-level filters from FilterRow; category dropdown is always included + const columnFilters = (requestData?.columns || []) + .filter(function (col) { return col?.name && col?.search?.value; }) + .map(function (col) { return { name: col.name, value: col.search.value }; }); + + columnFilters.push({ name: 'category', value: _selectedCategory }); + extras.columnFilters = columnFilters; + + return extras; + }, + responseCallback: function (result) { + return { recordsTotal: result.totalCount, recordsFiltered: result.totalCount, data: result.items }; + }, + actionButtons: commonTableActionButtons(l('Menu:Onboarding')).filter(b => b.id !== 'btn-toggle-filter'), + serverSideEnabled: true, + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dynamicButtonContainerId: 'dynamicButtonContainerId', + externalSearchId: 'search', + lengthMenu: [10, 25, 50] + }); + + _dataTable.select.style('single'); + + _dataTable.on('select', function (e, dt, type, indexes) { + if (type === 'row' && indexes.length) { + _selectedRow = dt.row(indexes[0]).data(); + manageActionButtons(); + } + }); + + _dataTable.on('deselect', function (e, dt, type) { + if (type === 'row' && dt.rows({ selected: true }).count() === 0) { + _selectedRow = null; + manageActionButtons(); + } + }); + } + + // ─── Action button state ────────────────────────────────────────────────── + + function manageActionButtons() { + const hasSelection = _selectedRow !== null; + $('#btn-open-onboarding').toggleClass('action-bar-btn-unavailable', !hasSelection); + $('#btn-create-tenant').toggleClass('action-bar-btn-unavailable', + !hasSelection || _selectedRow.status !== 'Approved'); + } + + // ─── Field value preview ───────────────────────────────────────────────── + + let _fieldValues = {}; + + function _updateFieldPreview(selectId, previewId, domBuilder) { + const key = $('#' + selectId).val(); + const raw = key ? (_fieldValues[key] ?? '') : ''; + const text = raw ? String(raw) : ''; + const $preview = $('#' + previewId); + if (domBuilder) { + // domBuilder() returns real jQuery/DOM elements built with .text(), not an HTML + // string — appended directly so there's no .html()-of-untrusted-string sink. + $preview.empty().append(domBuilder(text)); + } else { + $preview.text(text || '—').toggleClass('text-muted', !text); + } + } + + // ─── Create Tenant modal ────────────────────────────────────────────────── + + let _createTenantModal = new abp.ModalManager({ + viewUrl: abp.appPath + 'TenantManagement/Onboarding/CreateTenantModal', + modalClass: 'createTenantModal' + }); + + function _renderValidationResult(result) { + $('#onboarding-validation-loading').addClass('d-none'); + if (result.isValid) { + $('#onboarding-validation-result') + .html('
' + l('OnboardingModal:ValidationPassed') + '
') + .removeClass('d-none'); + $('#btn-confirm-create-tenant').prop('disabled', false); + } else { + let issuesHtml = (result.issues || []) + .map(function (i) { return '
  • ' + $('').text(i).html() + '
  • '; }) + .join(''); + $('#onboarding-validation-result') + .html('
    ' + l('OnboardingModal:ValidationFailed') + '
      ' + issuesHtml + '
    ') + .removeClass('d-none'); + } + } + + function _renderValidationFail() { + $('#onboarding-validation-loading').addClass('d-none'); + $('#onboarding-validation-result') + .html('
    ' + l('OnboardingModal:ValidateFailed') + '
    ') + .removeClass('d-none'); + } + + function _triggerValidation(applicationId) { + $('#onboarding-validation-loading').removeClass('d-none'); + $('#onboarding-validation-result').addClass('d-none'); + $('#btn-confirm-create-tenant').prop('disabled', true); + + const tenantNameFieldKey = $('#create-tenant-tenant-name-field').val() || null; + const superUsersFieldKey = $('#create-tenant-super-users-field').val() || null; + const branchFieldKey = $('#create-tenant-branch-field').val() || null; + const featuresFieldKey = $('#create-tenant-features-field').val() || null; + const ministryFieldKey = $('#create-tenant-ministry-field').val() || null; + const programAreaFieldKey = $('#create-tenant-program-area-field').val() || null; + + const params = new URLSearchParams(); + if (tenantNameFieldKey) params.append('tenantNameFieldKey', tenantNameFieldKey); + if (superUsersFieldKey) params.append('superUsersFieldKey', superUsersFieldKey); + if (branchFieldKey) params.append('branchFieldKey', branchFieldKey); + if (featuresFieldKey) params.append('featuresFieldKey', featuresFieldKey); + if (ministryFieldKey) params.append('ministryFieldKey', ministryFieldKey); + if (programAreaFieldKey) params.append('programAreaFieldKey', programAreaFieldKey); + const query = params.size ? '?' + params.toString() : ''; + + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/' + applicationId + '/validate' + query, + type: 'GET' + }).done(_renderValidationResult).fail(_renderValidationFail); + } + + function _onCreateTenantConfirm(applicationId) { + return function () { + const $btn = $(this); + $btn.prop('disabled', true); + $('#btn-cancel-create-tenant').prop('disabled', true); + $('#onboarding-validation-result').addClass('d-none'); + $('#onboarding-creating').removeClass('d-none'); + + const tenantNameFieldKey = $('#create-tenant-tenant-name-field').val() || null; + const superUsersFieldKey = $('#create-tenant-super-users-field').val() || null; + const branchFieldKey = $('#create-tenant-branch-field').val() || null; + const featuresFieldKey = $('#create-tenant-features-field').val() || null; + const ministryFieldKey = $('#create-tenant-ministry-field').val() || null; + const programAreaFieldKey = $('#create-tenant-program-area-field').val() || null; + + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/' + applicationId + '/create-tenant', + type: 'POST', + data: JSON.stringify({ tenantNameFieldKey, superUsersFieldKey, branchFieldKey, featuresFieldKey, ministryFieldKey, programAreaFieldKey }), + contentType: 'application/json' + }).done(function () { + abp.notify.success(l('OnboardingModal:CreateSuccess')); + _createTenantModal.close(); + _dataTable.ajax.reload(); + }).fail(function () { + $('#onboarding-creating').addClass('d-none'); + $btn.prop('disabled', false); + $('#btn-cancel-create-tenant').prop('disabled', false); + abp.notify.error(l('OnboardingModal:CreateFailed')); + }); + }; + } + + function _wireCreateTenantMappingHandlers(applicationId) { + $('#create-tenant-ministry-field').on('change', function () { + _updateFieldPreview('create-tenant-ministry-field', 'create-tenant-ministry-value'); + }); + $('#create-tenant-branch-field').on('change', function () { + _updateFieldPreview('create-tenant-branch-field', 'create-tenant-branch-value'); + }); + $('#create-tenant-program-area-field').on('change', function () { + _updateFieldPreview('create-tenant-program-area-field', 'create-tenant-program-area-value'); + }); + $('#create-tenant-features-field').on('change', function () { + _updateFieldPreview('create-tenant-features-field', 'create-tenant-features-value', _buildCheckboxBadgesPreview); + }); + $('#create-tenant-tenant-name-field').on('change', function () { + _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); + _triggerValidation(applicationId); + }); + $('#create-tenant-super-users-field').on('change', function () { + _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value', _buildSuperUsersPreview); + _triggerValidation(applicationId); + }); + } + + function _renderNoFieldsWarning() { + $('#onboarding-validation-loading').addClass('d-none'); + $('#onboarding-validation-result') + .html('
    ' + l('CreateTenantModal:NoFieldsWarning') + '
    ') + .removeClass('d-none'); + $('#btn-confirm-create-tenant').prop('disabled', true); + } + + function _loadCreateTenantFields(applicationId) { + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/column-schema', + type: 'GET', + data: { category: _selectedCategory } + }).done(function (schema) { + if (!schema?.columns?.length) { + _renderNoFieldsWarning(); + return; + } + _renderMappingDropdown('create-tenant-ministry-field', schema.columns, MINISTRY_CANONICALS, schema.ministryFieldKey); + _renderMappingDropdown('create-tenant-branch-field', schema.columns, BRANCH_CANONICALS, schema.branchFieldKey); + _renderMappingDropdown('create-tenant-program-area-field', schema.columns, PROGRAM_AREA_CANONICALS, schema.programAreaFieldKey); + _renderMappingDropdown('create-tenant-features-field', schema.columns, FEATURES_CANONICALS, schema.featuresFieldKey); + _renderMappingDropdown('create-tenant-tenant-name-field', schema.columns, TENANT_NAME_CANONICALS, schema.tenantNameFieldKey); + _renderMappingDropdown('create-tenant-super-users-field', schema.columns, SUPER_USERS_CANONICALS, schema.superUsersFieldKey); + _updateFieldPreview('create-tenant-ministry-field', 'create-tenant-ministry-value'); + _updateFieldPreview('create-tenant-branch-field', 'create-tenant-branch-value'); + _updateFieldPreview('create-tenant-program-area-field', 'create-tenant-program-area-value'); + _updateFieldPreview('create-tenant-features-field', 'create-tenant-features-value', _buildCheckboxBadgesPreview); + _updateFieldPreview('create-tenant-tenant-name-field', 'create-tenant-tenant-name-value'); + _updateFieldPreview('create-tenant-super-users-field', 'create-tenant-super-users-value', _buildSuperUsersPreview); + $('#create-tenant-field-mapping').show(); + _wireCreateTenantMappingHandlers(applicationId); + _triggerValidation(applicationId); + }).fail(function () { + _renderValidationFail(); + }); + } + + abp.modals.createTenantModal = function () { + return { + initModal: function (publicApi, args) { + const applicationId = args.id; + + try { + const raw = document.getElementById('create-tenant-fields-json'); + _fieldValues = raw ? JSON.parse(raw.textContent) : {}; + } catch { _fieldValues = {}; } + + _loadCreateTenantFields(applicationId); + $('#btn-confirm-create-tenant').on('click', _onCreateTenantConfirm(applicationId)); + } + }; + }; + + // ─── Jaro-Winkler field auto-detection ─────────────────────────────────── + + function _normalizeLabel(s) { + return s + .replaceAll(/([a-z])([A-Z])/g, '$1 $2') + .replaceAll(/[_-]+/g, ' ') + .toLowerCase() + .trim(); + } + + function _computeJaroMatchings(s1, s2, matchDist) { + const l1 = s1.length, l2 = s2.length; + const s1m = new Array(l1).fill(false), s2m = new Array(l2).fill(false); + let matches = 0; + for (let i = 0; i < l1; i++) { + const lo = Math.max(0, i - matchDist), hi = Math.min(i + matchDist + 1, l2); + for (let j = lo; j < hi; j++) { + if (s2m[j] || s1[i] !== s2[j]) continue; + s1m[i] = s2m[j] = true; matches++; break; + } + } + return { s1m, s2m, matches }; + } + + function _computeJaroTranspositions(s1, s2, s1m, s2m) { + let k = 0, transpositions = 0; + for (let i = 0; i < s1.length; i++) { + if (!s1m[i]) continue; + while (!s2m[k]) k++; + if (s1[i] !== s2[k]) transpositions++; + k++; + } + return transpositions; + } + + function _jaroWinkler(s1, s2) { + if (s1 === s2) return 1; + const l1 = s1.length, l2 = s2.length; + if (!l1 || !l2) return 0; + const matchDist = Math.max(Math.floor(Math.max(l1, l2) / 2) - 1, 0); + const { s1m, s2m, matches } = _computeJaroMatchings(s1, s2, matchDist); + if (!matches) return 0; + const transpositions = _computeJaroTranspositions(s1, s2, s1m, s2m); + const jaro = (matches / l1 + matches / l2 + (matches - transpositions / 2) / matches) / 3; + let prefix = 0; + for (let p = 0; p < Math.min(4, l1, l2); p++) { + if (s1[p] === s2[p]) prefix++; else break; + } + return jaro + prefix * 0.1 * (1 - jaro); + } + + const TENANT_NAME_CANONICALS = ['tenant name', 'organization name', 'company name', 'program name', 'applicant name', 'tenant abbreviation']; + const SUPER_USERS_CANONICALS = ['super user', 'super users', 'admin email', 'program manager', 'manager email', 'administrator', 'user email']; + const MINISTRY_CANONICALS = ['ministry', 'ministry name', 'government ministry', 'responsible ministry']; + const BRANCH_CANONICALS = ['branch', 'division branch', 'ministry branch', 'business branch']; + const PROGRAM_AREA_CANONICALS = ['program area', 'program area name', 'program name', 'program']; + const FEATURES_CANONICALS = ['features', 'feature flags', 'program features', 'modules', 'enabled features', 'features to be enabled']; + const MATCH_THRESHOLD = 0.85; + + function _bestMatch(fields, canonicals) { + let best = null, bestScore = 0; + fields.forEach(function (f) { + const norm = _normalizeLabel(f.label || f.key); + const score = Math.max(...canonicals.map(function (c) { return _jaroWinkler(norm, c); })); + if (score > bestScore) { bestScore = score; best = f.key; } + }); + return bestScore >= MATCH_THRESHOLD ? best : null; + } + + function _renderMappingDropdown(selectId, fields, canonicals, savedKey) { + const $sel = $('#' + selectId); + $sel.find('option:not(:first)').remove(); + fields.forEach(function (f) { + $sel.append(''); + }); + const savedKeyValid = savedKey && fields.some(function (f) { return f.key === savedKey; }); + const pick = (savedKeyValid ? savedKey : null) || _bestMatch(fields, canonicals); + if (pick) $sel.val(pick); + } + + // ─── Document ready ─────────────────────────────────────────────────────── + + function _loadSchemaAndInitTable() { + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/column-schema', + type: 'GET', + data: { category: _selectedCategory } + }).done(function (schema) { + initTable(schema); + }).fail(function () { + initTable(null); + }); + } + + $(function () { + abp.ajax({ + url: abp.appPath + 'api/onboarding-requests/categories', + type: 'GET' + }).done(function (categories) { + const categoryList = categories || ['Onboarding']; + const $sel = $('#onboarding-category-filter'); + $sel.empty(); + categoryList.forEach(function (cat) { + $sel.append(''); + }); + + const savedCategory = localStorage.getItem(CATEGORY_STORAGE_KEY); + _selectedCategory = (savedCategory && categoryList.includes(savedCategory)) ? savedCategory : 'Onboarding'; + $sel.val(_selectedCategory); + }).always(function () { + _loadSchemaAndInitTable(); + }); + + $('#onboarding-category-filter').on('change', function () { + _selectedCategory = $(this).val() || 'Onboarding'; + localStorage.setItem(CATEGORY_STORAGE_KEY, _selectedCategory); + _loadSchemaAndInitTable(); + }); + + $('#btn-open-onboarding').on('click', function () { + if (!_selectedRow) return; + globalThis.location.href = '/GrantApplications/Details?ApplicationId=' + _selectedRow.id; + }); + + $('#btn-create-tenant').on('click', function () { + if (!_selectedRow) return; + _createTenantModal.open({ id: _selectedRow.id }); + }); + }); +})(); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/OnboardingPageModel.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/OnboardingPageModel.cs new file mode 100644 index 0000000000..06692db6a7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/OnboardingPageModel.cs @@ -0,0 +1,12 @@ +using Unity.TenantManagement.Web; +using Volo.Abp.AspNetCore.Mvc.UI.RazorPages; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Onboarding; + +public abstract class OnboardingPageModel : AbpPageModel +{ + protected OnboardingPageModel() + { + ObjectMapperContext = typeof(UnityTenantManagementWebModule); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/_ViewImports.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/_ViewImports.cshtml new file mode 100644 index 0000000000..231948b339 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Onboarding/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bootstrap +@addTagHelper *, Volo.Abp.AspNetCore.Mvc.UI.Bundling diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml new file mode 100644 index 0000000000..8cea4764ca --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml @@ -0,0 +1,194 @@ +@page +@using Microsoft.AspNetCore.Mvc.Localization +@using Microsoft.Extensions.Localization +@using Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.TagHelpers.Modal +@using Volo.Abp.Data +@using Volo.Abp.Localization +@using Volo.Abp.ObjectExtending +@using Volo.Abp.TenantManagement.Localization +@using Unity.TenantManagement +@using Unity.TenantManagement.Web.Pages.TenantManagement.Tenants +@model ConfigurationModalModel +@inject IHtmlLocalizer L +@inject IStringLocalizerFactory StringLocalizerFactory +@{ + Layout = null; +} +
    + + + + + + + + +
    + +
    + + + + + +
    + + +
    + + @foreach (var propertyInfo in ObjectExtensionManager.Instance.GetProperties().Where(p => !p.Name.EndsWith("_Text"))) + { + if (propertyInfo.Type.IsEnum || !propertyInfo.Lookup.Url.IsNullOrEmpty()) + { + if (propertyInfo.Type.IsEnum) + { + Model.Tenant.ExtraProperties.ToEnum(propertyInfo.Name, propertyInfo.Type); + } + + } + else + { + + } + } +
    + + @if (Model.CanManageManagers) + { +
    + +
    +
    + Current Program Managers + +
    +
    + Loading... +
    +
    +
    + +
    + +
    Assign Program Manager
    +
    + + +
    + + + + + + +
    + } + + @if (Model.CanManageConnectionStrings) + { +
    +
    + + + Displayed decrypted. Saved encrypted automatically. +
    +
    + + + Displayed decrypted. Saved encrypted automatically. +
    +
    + } + + @if (Model.CanManageFeatures) + { +
    + +
    + +
    + } + +
    +
    + + + + +
    +
    diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs new file mode 100644 index 0000000000..3f5c9cd4c2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/ConfigurationModal.cshtml.cs @@ -0,0 +1,155 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Unity.GrantManager.Integrations; +using Unity.Modules.Shared.Permissions; +using Volo.Abp.Domain.Entities; +using Volo.Abp.FeatureManagement; +using Volo.Abp.Features; +using Volo.Abp.ObjectExtending; +using Volo.Abp.TenantManagement; +using Volo.Abp.Validation; + +namespace Unity.TenantManagement.Web.Pages.TenantManagement.Tenants; + +public class ConfigurationModalModel( + ITenantAppService tenantAppService, + ICasClientCodeLookupService lookupService, + IFeatureAppService featureAppService) : TenantManagementPageModel +{ + [BindProperty] + public TenantInfoModel Tenant { get; set; } = null!; + + [BindProperty] + public TenantConnectionStringsDto ConnectionStrings { get; set; } = new(); + + [BindProperty] + public string? SelectedManagerUserIdentifier { get; set; } + + [BindProperty] + public string? FeaturesJson { get; set; } + + public ManagerInfoModel Manager { get; set; } = null!; + + public List CasClientOptions { get; set; } = []; + + public bool CanManageConnectionStrings { get; set; } + + public bool CanManageFeatures { get; set; } + + public bool CanManageManagers { get; set; } + + public virtual async Task OnGetAsync(Guid id) + { + var tenantDto = await tenantAppService.GetAsync(id); + + Tenant = ObjectMapper.Map(tenantDto); + Manager = ObjectMapper.Map(tenantDto); + CasClientOptions = await lookupService.GetActiveOptionsAsync(); + + CanManageConnectionStrings = (await AuthorizationService + .AuthorizeAsync(User, TenantManagementPermissions.Tenants.ManageConnectionStrings)).Succeeded; + + CanManageFeatures = (await AuthorizationService + .AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded; + + CanManageManagers = CanManageFeatures; + + if (CanManageConnectionStrings) + { + ConnectionStrings = await tenantAppService.GetConnectionStringsAsync(id); + } + + return Page(); + } + + public virtual async Task OnPostAsync() + { + ValidateModel(); + + var input = ObjectMapper.Map(Tenant); + await tenantAppService.UpdateAsync(Tenant.Id, input); + + if (!string.IsNullOrWhiteSpace(SelectedManagerUserIdentifier)) + { + await tenantAppService.AssignManagerAsync(new TenantAssignManagerDto + { + TenantId = Tenant.Id, + UserIdentifier = SelectedManagerUserIdentifier + }); + } + + if ((await AuthorizationService.AuthorizeAsync(User, TenantManagementPermissions.Tenants.ManageConnectionStrings)).Succeeded) + { + await tenantAppService.UpdateConnectionStringsAsync(Tenant.Id, ConnectionStrings); + } + + if (!string.IsNullOrEmpty(FeaturesJson) && + (await AuthorizationService.AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded) + { + var featureUpdates = new List(); + foreach (var feature in JsonDocument.Parse(FeaturesJson).RootElement.EnumerateArray()) + { + featureUpdates.Add(new UpdateFeatureDto + { + Name = feature.GetProperty("name").GetString()!, + Value = feature.GetProperty("value").GetString()! + }); + } + await featureAppService.UpdateAsync( + TenantFeatureValueProvider.ProviderName, + Tenant.Id.ToString(), + new UpdateFeaturesDto { Features = featureUpdates }); + } + + return NoContent(); + } + + public class TenantInfoModel : ExtensibleObject, IHasConcurrencyStamp + { + [HiddenInput] + public Guid Id { get; set; } + + [Required] + [DynamicStringLength(typeof(TenantConsts), nameof(TenantConsts.MaxNameLength))] + [Display(Name = "DisplayName:TenantName")] + public string Name { get; set; } = string.Empty; + + public string Division { get; set; } = string.Empty; + public string Branch { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + + [Display(Name = "CAS Client Code")] + public string? CasClientCode { get; set; } + + [HiddenInput] + public string ConcurrencyStamp { get; set; } = string.Empty; + } + + public class ManagerInfoModel : ExtensibleObject + { + [HiddenInput] + public Guid Id { get; set; } + + [Display(Name = "DisplayName:TenantName")] + public string Name { get; set; } = string.Empty; + + [DisplayName("First Name")] + public string? FirstName { get; set; } + + [DisplayName("Last Name")] + public string? LastName { get; set; } + + [Required] + public string Directory { get; set; } = "IDIR"; + + [Required] + public string UserIdentifier { get; set; } = string.Empty; + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml index 4d492e9bd1..f410abec4f 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.cshtml @@ -19,8 +19,9 @@ ViewBag.PageTitle = L["Tenants"].Value; } @section styles { - + + } @section scripts { @@ -33,9 +34,17 @@ @section content_toolbar { @await Component.InvokeAsync(typeof(AbpPageToolbarViewComponent), new { pageName = typeof(IndexModel).FullName }) } - - - - - - +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + + +
    diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js index cc16f58fd0..3b7b834ed4 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js @@ -1,157 +1,90 @@ (function () { let l = abp.localization.getResource('AbpTenantManagement'); + let lGm = abp.localization.getResource('GrantManager'); let _tenantAppService = unity.tenantManagement.tenant; let _userImportService = unity.grantManager.identity.userImport; let _casClientCodeHash = {}; - let _editModal = new abp.ModalManager( - abp.appPath + 'TenantManagement/Tenants/EditModal' - ); let _createModal = new abp.ModalManager({ viewUrl: abp.appPath + 'TenantManagement/Tenants/CreateModal', modalClass: 'createTenant' } ); - let _featuresModal = new abp.ModalManager( - abp.appPath + 'FeatureManagement/FeatureManagementModal' - ); - let _assignManagerModal = new abp.ModalManager({ - viewUrl: abp.appPath + 'TenantManagement/Tenants/AssignManagerModal', - modalClass: 'assignManager' + + let _configurationModal = new abp.ModalManager({ + viewUrl: abp.appPath + 'TenantManagement/Tenants/ConfigurationModal', + modalClass: 'configurationModal' } ); let _dataTable = null; - abp.ui.extensions.entityActions.get('tenantManagement.tenant').addContributor( - function(actionList) { - return actionList.addManyTail( - [ - { - text: l('Edit'), - visible: abp.auth.isGranted( - 'UnityTenantManagement.Tenants.Update' - ), - action: function (data) { - _editModal.open({ - id: data.record.id, - }); - }, - }, - { - text: l('Assign Manager'), - visible: abp.auth.isGranted( - 'UnityTenantManagement.Tenants.Create' - ), - action: function (data) { - _assignManagerModal.open({ - id: data.record.id, - }); - }, - }, - { - text: l('Features'), - visible: abp.auth.isGranted( - 'AbpTenantManagement.Tenants.ManageFeatures' - ), - action: function (data) { - _featuresModal.open({ - providerName: 'T', - providerKey: data.record.id, - }); - }, - }, - { - text: l('Delete'), - visible: abp.auth.isGranted( - 'UnityTenantManagement.Tenants.Delete' - ), - confirmMessage: function (data) { - return l( - 'TenantDeletionConfirmationMessage', - data.record.name - ); - }, - action: function (data) { - _tenantAppService - .delete(data.record.id) - .then(function () { - _dataTable.ajax.reloadEx(); - abp.notify.success(l('SuccessfullyDeleted')); - }); - }, - } - ] - ); + // ─── Actions column renderer ────────────────────────────────────────────── + + function _buildActionsCell(id, name) { + let items = []; + if (abp.auth.isGranted('UnityTenantManagement.Tenants.Update') || abp.auth.isGranted('ITOperations')) { + items.push('' + lGm('TenantList:ConfigurationAction') + ''); } - ); + if (abp.auth.isGranted('UnityTenantManagement.Tenants.Delete')) { + items.push('' + l('Delete') + ''); + } + if (!items.length) return ''; + return '
    '; + } - abp.ui.extensions.tableColumns.get('tenantManagement.tenant').addContributor( - function (columnList) { - columnList.addManyTail( - [ - { - title: l("Actions"), - orderable: false, - rowAction: { - items: abp.ui.extensions.entityActions.get('tenantManagement.tenant').actions.toArray() - } - }, - { - title: l("TenantName"), - data: 'name', - }, - { - title: l("Division"), - data: 'division', - }, - { - title: l("Branch"), - data: 'branch', - }, - { - title: l("Description"), - data: 'description', - }, - { - title: "CAS Client Code", - data: 'casClientCode', - render: function (data, type, row) { - if (type === 'display') { - const code = row.casClientCode || ''; - const displayValue = _casClientCodeHash[code] || ''; - return displayValue; - } - return data; - } - }, - { - title: l("Id"), - data: 'id', - } - ] - ); + // ─── Column definitions ─────────────────────────────────────────────────── + + let listColumns = [ + { + title: l('Actions'), + name: 'actions', + data: 'id', + orderable: false, + className: 'notexport text-center', + index: 0, + render: function (data, type, row) { + return type === 'display' ? _buildActionsCell(data, row.name) : ''; + } }, - 0 //adds as the first contributor - ); + { title: l('TenantName'), data: 'name', name: 'name', index: 1 }, + { title: lGm('TenantList:LicencePlate'), data: 'licencePlate', name: 'licencePlate', index: 2 }, + { title: l('Division'), data: 'division', name: 'division', index: 3 }, + { title: l('Branch'), data: 'branch', name: 'branch', index: 4 }, + { title: l('Description'), data: 'description', name: 'description', index: 5 }, + { + title: lGm('TenantList:CasClientCode'), + data: 'casClientCode', + name: 'casClientCode', + index: 6, + render: function (data, type, row) { + if (type === 'display') { + return _casClientCodeHash[row.casClientCode || ''] || ''; + } + return data; + } + }, + { title: l('Id'), data: 'id', name: 'id', index: 7 } + ]; - let inputAction = function (requestData, dataTableSettings) { - return { - directory: 'IDIR', - firstName: $('#create-tenant-firstName').val(), - lastName: $('#create-tenant-lastName').val() - }; - }; + let defaultVisibleColumns = ['actions', 'name', 'licencePlate', 'division', 'branch', 'description', 'casClientCode']; let responseCallback = function (result) { return { - recordsTotal: result.length, - recordsFiltered: result.length, - data: result + recordsTotal: result.totalCount, + recordsFiltered: result.totalCount, + data: result.items }; }; + // ─── Modal setup: Create tenant ─────────────────────────────────────────── + let _filterDataTable = null; + let _configFilterDataTable = null; let setupCreateTenantModal = function () { let _$filterTable = $('#UserSearchTable'); @@ -166,8 +99,20 @@ searching: false, ajax: abp.libs.datatables.createAjax( _userImportService.search, - inputAction, - responseCallback + function () { + return { + directory: 'IDIR', + firstName: $('#create-tenant-firstName').val(), + lastName: $('#create-tenant-lastName').val() + }; + }, + function (result) { + return { + recordsTotal: result.length, + recordsFiltered: result.length, + data: result + }; + } ), select: { style: 'single', @@ -191,17 +136,16 @@ className: 'data-table-header' }], }) - ) + ); $('#TenantAdminSearchButton').click(function (e) { e.preventDefault(); - _filterDataTable.ajax.reloadEx(); + _filterDataTable.ajax.reload(); $('#create-tenant-btn').attr('disabled', true); }); $('#cancel-tenant-btn').click(function (e) { _createModal.close(); - _assignManagerModal.close(); }); _filterDataTable.on('select', function (e, dt, type, indexes) { @@ -216,15 +160,7 @@ $('#create-tenant-admin-id').val(); $('#create-tenant-btn').attr('disabled', true); }); - - _createModal.onResult(function () { - _dataTable.ajax.reloadEx(); - }); - - _assignManagerModal.onResult(function () { - _dataTable.ajax.reloadEx(); - }); - } + }; _createModal.onOpen(function () { setTimeout(() => { @@ -232,29 +168,252 @@ }); }); - _assignManagerModal.onOpen(function () { + _configurationModal.onOpen(function () { setTimeout(() => { - _filterDataTable.columns.adjust().draw(); + if (_configFilterDataTable) { + _configFilterDataTable.columns.adjust().draw(); + } }); }); + function _createTenantInitModal(publicApi, args) { + setupCreateTenantModal(); + } + abp.modals.createTenant = function () { - let initModal = function (publicApi, args) { - setupCreateTenantModal(); + return { initModal: _createTenantInitModal }; + }; + + // ─── Modal setup: Configuration ─────────────────────────────────────────── + + let _configTenantId = null; + let _featuresLoaded = false; + + function _renderFeatureItem(feature) { + let id = 'ft-' + feature.name.replaceAll('.', '-'); + let checked = feature.value === 'true' ? ' checked' : ''; + return '
    ' + + '' + + '' + + '
    '; + } + + function _renderFeatureGroups(groups) { + if (!groups?.length) { + return '

    No features available.

    '; + } + let html = ''; + groups.forEach(function (group) { + html += '
    '; + html += '
    ' + (group.displayName || group.name) + '
    '; + group.features?.forEach(function (feature) { + html += _renderFeatureItem(feature); + }); + html += '
    '; + }); + return html; + } + + function _loadManagersTab(tenantId) { + $('#config-managers-loading').show(); + $('#config-managers-content').html(''); + + abp.ajax({ + url: abp.appPath + 'api/multi-tenancy/tenants/' + tenantId + '/managers', + type: 'GET' + }).done(function (result) { + $('#config-managers-loading').hide(); + $('#config-managers-count').text(result?.length ?? 0); + if (result?.length) { + let html = '
      '; + result.forEach(function (m) { + html += '
    • ' + + '' + + '' + $('').text(m.displayName).html() + '' + + (m.email ? '(' + $('').text(m.email).html() + ')' : '') + + '
    • '; + }); + html += '
    '; + $('#config-managers-content').html(html); + } else { + $('#config-managers-content').html('

    No program managers assigned.

    '); + } + }).fail(function () { + $('#config-managers-loading').hide(); + $('#config-managers-content').html('

    Failed to load program managers.

    '); + }); + } + + function _loadFeaturesTab(tenantId) { + $('#config-features-loading').show(); + $('#config-features-content').html(''); + $('#config-features-actions').hide(); + + abp.ajax({ + url: abp.appPath + 'api/feature-management/features', + type: 'GET', + data: { providerName: 'T', providerKey: tenantId } + }).done(function (result) { + $('#config-features-loading').hide(); + $('#config-features-content').html(_renderFeatureGroups(result.groups)); + $('#config-features-actions').show(); + _captureFeaturesToForm(); + }).fail(function () { + $('#config-features-loading').hide(); + $('#config-features-content').html('
    Failed to load features. Please try again.
    '); + }); + } + + function _captureFeaturesToForm() { + if (!_featuresLoaded) return; + let features = []; + $('#config-features-content input[type="checkbox"]').each(function () { + features.push({ name: $(this).data('feature-name'), value: $(this).prop('checked').toString() }); + }); + $('#config-features-json').val(JSON.stringify(features)); + } + + function _specializationCheckboxChange() { + if ($(this).prop('checked')) { + let $allSpecs = $('[data-feature-group="Specializations"] input[type="checkbox"]'); + $allSpecs.not(this).prop('checked', false); + } + } + + function _configSearchInputAction() { + let field = $('#config-search-field').val(); + let value = $('#config-search-value').val(); + if (field === 'firstAndLast') { + let parts = value.trim().replaceAll(/\s+/g, ' ').split(' '); + return { + directory: 'IDIR', + firstName: parts[0] || '', + lastName: parts[1] || '', + email: '' + }; + } + return { + directory: 'IDIR', + firstName: field === 'firstName' ? value : '', + lastName: field === 'lastName' ? value : '', + email: field === 'email' ? value : '' }; - return { initModal: initModal }; } - abp.modals.assignManager = function () { - let initModal = function (publicApi, args) { - setupCreateTenantModal(); + function _configSearchResponseCallback(result) { + return { recordsTotal: result.length, recordsFiltered: result.length, data: result }; + } + + function _configurationModalInitModal(publicApi, args) { + _configTenantId = args.id; + + _loadManagersTab(_configTenantId); + + _configFilterDataTable = $('#ConfigUserSearchTable').DataTable( + abp.libs.datatables.normalizeConfiguration({ + order: [[0, 'asc']], + processing: true, + serverSide: false, + scrollX: true, + paging: true, + searching: false, + ajax: abp.libs.datatables.createAjax( + _userImportService.search, + _configSearchInputAction, + _configSearchResponseCallback + ), + select: { style: 'single' }, + columnDefs: [ + { title: 'First Name', name: 'firstName', data: 'firstName', className: 'data-table-header' }, + { title: 'Last Name', name: 'lastName', data: 'lastName', className: 'data-table-header' }, + { title: 'Display Name', name: 'displayName', data: 'displayName', className: 'data-table-header' }, + { title: 'Email', name: 'email', data: 'email', className: 'data-table-header' } + ] + }) + ); + + $('#config-search-field').on('change', function () { + let placeholders = { + firstName: 'At least 2 characters...', + lastName: 'At least 2 characters...', + firstAndLast: 'e.g. John Smith', + email: 'At least 2 characters...' + }; + $('#config-search-value').val('').attr('placeholder', placeholders[$(this).val()] || 'At least 2 characters...'); + }); + + $('#ConfigTenantAdminSearchButton').click(function (e) { + e.preventDefault(); + if ($('#config-search-value').val().trim().length < 2) { + abp.notify.warn(lGm('TenantList:SearchMinChars')); + return; + } + _configFilterDataTable.ajax.reload(); + $('#config-selected-user-identifier').val(''); + $('#config-selected-user-display').hide(); + }); + + _configFilterDataTable.on('select', function (e, dt, type, indexes) { + if (type === 'row') { + let selectedData = _configFilterDataTable.row(indexes).data(); + $('#config-selected-user-identifier').val(selectedData.userGuid); + let displayName = selectedData.displayName || (selectedData.firstName + ' ' + selectedData.lastName).trim(); + $('#config-selected-user-name').text(displayName); + $('#config-selected-user-display').show(); + } + }); + + _configFilterDataTable.on('deselect', function () { + $('#config-selected-user-identifier').val(''); + $('#config-selected-user-display').hide(); + }); + + _featuresLoaded = false; + $('#tab-features').on('shown.bs.tab', function () { + if (!_featuresLoaded) { + _featuresLoaded = true; + _loadFeaturesTab(_configTenantId); + } + }); + $('#config-features-content').on('change', '[data-feature-group="Specializations"] input[type="checkbox"]', _specializationCheckboxChange); + $('#config-features-content').on('change', 'input[type="checkbox"]', _captureFeaturesToForm); + + $('#pane-features').closest('form').on('invalid-form.validate', function (e, validator) { + if (validator.errorList.length > 0) { + let $firstErrorPane = $(validator.errorList[0].element).closest('.tab-pane'); + if ($firstErrorPane.length) { + $('[data-bs-target="#' + $firstErrorPane.attr('id') + '"]').tab('show'); + } + } + }); + } + + abp.modals.configurationModal = function () { + return { initModal: _configurationModalInitModal }; + }; + + // ─── Delete confirmation ────────────────────────────────────────────────── + + function _onDeleteConfirmed(id) { + return function (confirmed) { + if (confirmed) { + _tenantAppService.delete(id).then(function () { + _dataTable.ajax.reload(); + abp.notify.success(l('SuccessfullyDeleted')); + }); + } }; - return { initModal: initModal }; } + function _confirmDeleteTenant(id, name) { + abp.message.confirm(l('TenantDeletionConfirmationMessage', name), _onDeleteConfirmed(id)); + } + + // ─── Document ready ─────────────────────────────────────────────────────── + $(function () { - let _$wrapper = $('#TenantsWrapper'); - // Parse CAS client code hash from hidden field data attribute let casClientCodeHashEl = document.getElementById('casClientCodeHashData'); try { @@ -263,38 +422,62 @@ console.warn('Failed to parse CAS client code hash', e); } - _dataTable = _$wrapper.find('table').DataTable( - abp.libs.datatables.normalizeConfiguration({ - order: [[1, 'asc']], - processing: true, - paging: true, - scrollX: true, - serverSide: true, - ajax: abp.libs.datatables.createAjax(_tenantAppService.getList), - columnDefs: abp.ui.extensions.tableColumns.get('tenantManagement.tenant').columns.toArray(), - }) - ); + _dataTable = initializeDataTable({ + dt: $('#TenantsTable'), + listColumns: listColumns, + defaultVisibleColumns: defaultVisibleColumns, + defaultSortColumn: 1, + dataEndpoint: _tenantAppService.getList, + responseCallback: responseCallback, + actionButtons: commonTableActionButtons('Tenants').filter(function (b) { return b.id !== 'btn-toggle-filter'; }), + serverSideEnabled: false, + pagingEnabled: true, + reorderEnabled: true, + languageSetValues: {}, + dynamicButtonContainerId: 'dynamicButtonContainerId', + externalSearchId: 'search', + fixedHeaders: true + }); + + // Disable interactive row selection (selection is only ever driven via the API), + // without needing a "selectable" option on the shared initializeDataTable helper. + _dataTable.select.style('api'); _createModal.onResult(function () { - _dataTable.ajax.reloadEx(); + _dataTable.ajax.reload(); }); - _editModal.onResult(function () { - _dataTable.ajax.reloadEx(); + _configurationModal.onResult(function () { + _dataTable.ajax.reload(); }); - $('#AbpContentToolbar button[name=CreateTenant]').click(function (e) { + // Relocate the page-toolbar "New Tenant" button into the action bar, to the + // left of the Filter button, matching the Endpoints list layout. + $('#tenantCreateButtonContainer').append($('#AbpContentToolbar button[name=CreateTenant]')); + + $('#tenantCreateButtonContainer button[name=CreateTenant]').click(function (e) { e.preventDefault(); _createModal.open(); }); + + // Action column event delegation + $(document).on('click', '.tenant-action-config', function (e) { + e.preventDefault(); + _configurationModal.open({ id: $(this).data('id') }); + }); + + $(document).on('click', '.tenant-action-delete', function (e) { + e.preventDefault(); + _confirmDeleteTenant($(this).data('id'), $(this).data('name')); + }); }); - - // Use event delegation to handle dynamically loaded elements + + // ─── CAS client select handler (event delegation for dynamic elements) ──── + $(document).on('change', '.cas-client-select', function() { const $select = $(this); const selectedOption = $select.find('option:selected'); - - // Handle ministry field update + const ministryValue = selectedOption.data('ministry') || ''; const ministryTarget = $select.data('ministry-target'); if (ministryTarget) { @@ -303,8 +486,7 @@ $targetInput.val(ministryValue); } } - - // Handle CAS client code update + const casClientCode = selectedOption.data('cas-client-code'); if (casClientCode) { const $container = $select.closest('form, .modal-body'); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs index 2ff26b3542..07827e6ab9 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebMapperlyProfile.cs @@ -1,6 +1,7 @@ using Unity.TenantManagement.Web.Pages.TenantManagement.Tenants; using Volo.Abp.Mapperly; using static Unity.TenantManagement.Web.Pages.TenantManagement.Tenants.AssignManagerModalModel; +using static Unity.TenantManagement.Web.Pages.TenantManagement.Tenants.ConfigurationModalModel; namespace Unity.TenantManagement.Web; @@ -79,6 +80,66 @@ public override void Map(EditModalModel.TenantInfoModel source, TenantUpdateDto } } +public class TenantDtoToConfigurationTenantInfoMapper : MapperBase +{ + public override TenantInfoModel Map(TenantDto source) + { + var destination = new TenantInfoModel(); + Map(source, destination); + return destination; + } + + public override void Map(TenantDto source, TenantInfoModel destination) + { + destination.Id = source.Id; + destination.Name = source.Name; + destination.Division = source.Division; + destination.Branch = source.Branch; + destination.Description = source.Description; + destination.CasClientCode = source.CasClientCode; + destination.ConcurrencyStamp = source.ConcurrencyStamp; + TenantExtraPropertiesCopier.Copy(source, destination); + } +} + +public class ConfigurationTenantInfoToTenantUpdateDtoMapper : MapperBase +{ + public override TenantUpdateDto Map(TenantInfoModel source) + { + var destination = new TenantUpdateDto(); + Map(source, destination); + return destination; + } + + public override void Map(TenantInfoModel source, TenantUpdateDto destination) + { + destination.Name = source.Name; + destination.Division = source.Division; + destination.Branch = source.Branch; + destination.Description = source.Description; + destination.CasClientCode = source.CasClientCode ?? string.Empty; + destination.ConcurrencyStamp = source.ConcurrencyStamp; + TenantExtraPropertiesCopier.Copy(source, destination); + } +} + +public class TenantDtoToConfigurationManagerInfoMapper : MapperBase +{ + public override ManagerInfoModel Map(TenantDto source) + { + var destination = new ManagerInfoModel(); + Map(source, destination); + return destination; + } + + public override void Map(TenantDto source, ManagerInfoModel destination) + { + destination.Id = source.Id; + destination.Name = source.Name; + TenantExtraPropertiesCopier.Copy(source, destination); + } +} + public class TenantDtoToAssignManagerInfoMapper : MapperBase { public override AssignManagerInfoModel Map(TenantDto source) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs index 08f08f4142..f7bffd53c2 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/UnityTenantManagementWebModule.cs @@ -14,6 +14,7 @@ using Volo.Abp.UI.Navigation; using Volo.Abp.VirtualFileSystem; using Volo.Abp.Threading; +using Unity.Modules.Shared.Permissions; using Unity.TenantManagement.Web.Navigation; namespace Unity.TenantManagement.Web; @@ -55,10 +56,12 @@ public override void ConfigureServices(ServiceConfigurationContext context) Configure(options => { - options.Conventions.AuthorizePage("/TenantManagement/Tenants/Index", TenantManagementPermissions.Tenants.Default); + options.Conventions.AuthorizePage("/TenantManagement/Tenants/Index", TenantManagementPermissions.Policies.TenantsOrITOps); options.Conventions.AuthorizePage("/TenantManagement/Tenants/CreateModal", TenantManagementPermissions.Tenants.Create); options.Conventions.AuthorizePage("/TenantManagement/Tenants/EditModal", TenantManagementPermissions.Tenants.Update); options.Conventions.AuthorizePage("/TenantManagement/Tenants/AssignManagerModal", TenantManagementPermissions.Tenants.Create); + options.Conventions.AuthorizePage("/TenantManagement/Tenants/ConfigurationModal", TenantManagementPermissions.Policies.TenantsOrITOps); + options.Conventions.AuthorizePage("/TenantManagement/Onboarding/Index", IdentityConsts.ITOperationsPermissionName); }); Configure(options => @@ -95,7 +98,8 @@ public override void PostConfigureServices(ServiceConfigurationContext context) editFormTypes: new[] { typeof(Pages.TenantManagement.Tenants.EditModalModel.TenantInfoModel), - typeof(Pages.TenantManagement.Tenants.AssignManagerModalModel.AssignManagerInfoModel) + typeof(Pages.TenantManagement.Tenants.AssignManagerModalModel.AssignManagerInfoModel), + typeof(Pages.TenantManagement.Tenants.ConfigurationModalModel.TenantInfoModel) } ); }); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js index 7ffac83e74..c5014d6c15 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/wwwroot/client-proxies/multi-tenancy-proxy.js @@ -44,24 +44,86 @@ dataType: null }, ajaxParams)); }; - unity.tenantManagement.tenant.getDefaultConnectionString = function(id, ajaxParams) { + unity.tenantManagement.tenant.getConnectionStrings = function(id, ajaxParams) { return abp.ajax($.extend(true, { - url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/default-connection-string', + url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/connection-strings', type: 'GET' - }, { dataType: 'text' }, ajaxParams)); + }, ajaxParams)); }; - unity.tenantManagement.tenant.updateDefaultConnectionString = function(id, defaultConnectionString, ajaxParams) { + unity.tenantManagement.tenant.updateConnectionStrings = function(id, input, ajaxParams) { return abp.ajax($.extend(true, { - url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/default-connection-string' + abp.utils.buildQueryString([{ name: 'defaultConnectionString', value: defaultConnectionString }]) + '', + url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/connection-strings', type: 'PUT', - dataType: null + data: JSON.stringify(input) + }, ajaxParams)); + }; + + })(); + + // controller unity.tenantManagement.onboardingRequest + + (function(){ + + abp.utils.createNamespace(globalThis, 'unity.tenantManagement.onboardingRequest'); + + unity.tenantManagement.onboardingRequest.getList = function(input, ajaxParams) { + const qsParams = [ + { name: 'sorting', value: input.sorting }, + { name: 'skipCount', value: input.skipCount }, + { name: 'maxResultCount', value: input.maxResultCount }, + { name: 'category', value: input.category }, + { name: 'filter', value: input.filter } + ]; + (input.columnFilters || []).forEach(function(cf, i) { + qsParams.push( + { name: 'columnFilters[' + i + '].name', value: cf.name }, + { name: 'columnFilters[' + i + '].value', value: cf.value } + ); + }); + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests' + abp.utils.buildQueryString(qsParams) + '', + type: 'GET' }, ajaxParams)); }; - unity.tenantManagement.tenant.deleteDefaultConnectionString = function(id, ajaxParams) { + + unity.tenantManagement.onboardingRequest.get = function(id, ajaxParams) { return abp.ajax($.extend(true, { - url: abp.appPath + 'api/multi-tenancy/tenants/' + id + '/default-connection-string', - type: 'DELETE', - dataType: null + url: abp.appPath + 'api/onboarding-requests/' + id + '', + type: 'GET' + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.validate = function(id, input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/' + id + '/validate' + abp.utils.buildQueryString([ + { name: 'tenantNameFieldKey', value: input?.tenantNameFieldKey }, + { name: 'superUsersFieldKey', value: input?.superUsersFieldKey } + ]) + '', + type: 'GET' + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.createTenant = function(id, input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/' + id + '/create-tenant', + type: 'POST', + data: JSON.stringify(input) + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.getColumnSchema = function(input, ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/column-schema' + abp.utils.buildQueryString([ + { name: 'category', value: input?.category } + ]) + '', + type: 'GET' + }, ajaxParams)); + }; + + unity.tenantManagement.onboardingRequest.getAvailableCategories = function(ajaxParams) { + return abp.ajax($.extend(true, { + url: abp.appPath + 'api/onboarding-requests/categories', + type: 'GET' }, ajaxParams)); }; diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs new file mode 100644 index 0000000000..e5e7e990a6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Onboarding/OnboardingFeatureMapTests.cs @@ -0,0 +1,73 @@ +using Shouldly; +using Xunit; + +namespace Unity.TenantManagement.Onboarding; + +public class OnboardingFeatureMapTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ResolveFeatureKeys_BlankInput_ReturnsEmpty(string input) + { + var result = OnboardingFeatureMap.ResolveFeatureKeys(input); + + result.ShouldBeEmpty(); + } + + [Fact] + public void ResolveFeatureKeys_UnrecognizedTokens_AreDroppedNotPassedThrough() + { + // The "Features" worksheet field is filled out by the applicant submitting the onboarding + // request, so unrecognized/arbitrary tokens must never reach the real ABP feature name list. + var result = OnboardingFeatureMap.ResolveFeatureKeys("Payments,SomeArbitraryFeatureKey,DROP TABLE Tenants"); + + result.ShouldBe(["Unity.Payments"]); + } + + [Theory] + [InlineData("Payments,Reporting")] + [InlineData("Payments;Reporting")] + [InlineData("Payments|Reporting")] + public void ResolveFeatureKeys_AllDelimiterVariants_AreSupported(string input) + { + var result = OnboardingFeatureMap.ResolveFeatureKeys(input); + + result.ShouldBe(["Unity.Payments", "Unity.Reporting"]); + } + + [Fact] + public void ResolveFeatureKeys_KeyMatchingIsCaseInsensitive() + { + var result = OnboardingFeatureMap.ResolveFeatureKeys("payments,AIREPORTING"); + + result.ShouldBe(["Unity.Payments", "Unity.AIReporting"]); + } + + [Fact] + public void ResolveFeatureKeys_DuplicateTokens_CollapseToASingleKey() + { + var result = OnboardingFeatureMap.ResolveFeatureKeys("Payments,Payments,Flex"); + + result.ShouldBe(["Unity.Payments", "Unity.Flex"]); + } + + [Fact] + public void ResolveFeatureKeys_CheckboxGroupJsonFormat_OnlyEnabledKeysAreResolved() + { + var json = """[{"key":"aiReporting","value":true},{"key":"aiScoring","value":false}]"""; + + var result = OnboardingFeatureMap.ResolveFeatureKeys(json); + + result.ShouldBe(["Unity.AIReporting"]); + } + + [Fact] + public void ResolveFeatureKeys_MalformedJsonArray_DoesNotThrowAndReturnsEmpty() + { + var result = OnboardingFeatureMap.ResolveFeatureKeys("[ this is not valid json"); + + result.ShouldBeEmpty(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs new file mode 100644 index 0000000000..7667f51dfa --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/OnboardingRequestAppServiceTests.cs @@ -0,0 +1,472 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Values; +using Unity.Flex.WorksheetInstances; +using Unity.TenantManagement.Onboarding; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.SettingManagement; +using Xunit; + +namespace Unity.TenantManagement; + +public class OnboardingRequestAppServiceTests : AbpTenantManagementApplicationTestBase +{ + private const string ApplicationCorrelationProvider = "Application"; + + private IOnboardingApplicationProvider _applicationProvider = null!; + private IWorksheetInstanceAppService _worksheetInstanceAppService = null!; + private IWorksheetAppService _worksheetAppService = null!; + private IOnboardingUserLookup _userLookup = null!; + private ITenantAppService _tenantAppService = null!; + private ISettingManager _settingManager = null!; + + private readonly IOnboardingRequestAppService _appService; + + protected override void AfterAddApplication(IServiceCollection services) + { + _applicationProvider = Substitute.For(); + _applicationProvider.GetAllIdsAsync(Arg.Any()).Returns(new List()); + _applicationProvider.GetFormVersionIdsAsync(Arg.Any()).Returns(new List()); + _applicationProvider.GetMappedCoreFieldColumnsAsync(Arg.Any()).Returns(new List()); + _applicationProvider.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new PagedResultDto(0, [])); + + _worksheetInstanceAppService = Substitute.For(); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), Arg.Any()) + .Returns(new List()); + + _worksheetAppService = Substitute.For(); + _userLookup = Substitute.For(); + _tenantAppService = Substitute.For(); + + _settingManager = Substitute.For(); + _settingManager.GetOrNullAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((string)null); + + services.AddSingleton(_applicationProvider); + services.AddSingleton(_worksheetInstanceAppService); + services.AddSingleton(_worksheetAppService); + services.AddSingleton(_userLookup); + services.AddSingleton(_tenantAppService); + services.AddSingleton(_settingManager); + } + + public OnboardingRequestAppServiceTests() + { + _appService = GetRequiredService(); + } + + private static WorksheetInstanceDataDto WorksheetInstanceFor(Guid correlationId, params (string Key, string Value)[] fields) => + new() + { + Id = Guid.NewGuid(), + CorrelationId = correlationId, + WorksheetId = Guid.NewGuid(), + CurrentValue = System.Text.Json.JsonSerializer.Serialize(new WorksheetInstanceValue + { + Values = fields.Select(f => new FieldInstanceValue(f.Key, f.Value)).ToList() + }) + }; + + [Fact] + public async Task GetListAsync_DynamicColumnFilters_AreCaseInsensitiveAndAndCombined() + { + var matching = Guid.NewGuid(); + var wrongMinistry = Guid.NewGuid(); + var wrongBranch = Guid.NewGuid(); + var allIds = new List { matching, wrongMinistry, wrongBranch }; + + _applicationProvider.GetAllIdsAsync("Onboarding").Returns(allIds); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(matching, ("ministry", "Health"), ("branch", "North")), + WorksheetInstanceFor(wrongMinistry, ("ministry", "Finance"), ("branch", "North")), + WorksheetInstanceFor(wrongBranch, ("ministry", "Health"), ("branch", "South")) + }); + + IReadOnlyList capturedMatchIds = null; + _applicationProvider + .When(p => p.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>())) + .Do(call => capturedMatchIds = call.ArgAt>(7)); + + await _appService.GetListAsync(new OnboardingListRequestDto + { + ColumnFilters = [ + new ColumnFilterDto { Name = "ministry", Value = "HEALTH" }, + new ColumnFilterDto { Name = "branch", Value = "nor" } + ] + }); + + capturedMatchIds.ShouldNotBeNull(); + capturedMatchIds.ShouldBe([matching]); + } + + [Fact] + public async Task GetListAsync_DynamicColumnSort_StripsFieldsPrefixAndSortsInMemoryWithPaging() + { + var idA = Guid.NewGuid(); + var idB = Guid.NewGuid(); + var idC = Guid.NewGuid(); + var allIds = new List { idA, idB, idC }; + + _applicationProvider.GetAllIdsAsync("Onboarding").Returns(allIds); + _applicationProvider.GetPagedListAsync(0, int.MaxValue, null, "Onboarding", null, null, null, null) + .Returns(new PagedResultDto(3, [ + new OnboardingApplicationRecord { Id = idA, Category = "Onboarding" }, + new OnboardingApplicationRecord { Id = idB, Category = "Onboarding" }, + new OnboardingApplicationRecord { Id = idC, Category = "Onboarding" } + ])); + + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(idA, ("score", "Charlie")), + WorksheetInstanceFor(idB, ("score", "Apple")), + WorksheetInstanceFor(idC, ("score", "Bravo")) + }); + + var result = await _appService.GetListAsync(new OnboardingListRequestDto + { + Sorting = "fields.score asc", + SkipCount = 0, + MaxResultCount = 2 + }); + + result.TotalCount.ShouldBe(3); + result.Items.Select(i => i.Id).ShouldBe([idB, idC]); + } + + [Fact] + public async Task GetColumnSchemaAsync_DedupesFieldsAcrossWorksheets_PreservesOrder() + { + var formVersionId = Guid.NewGuid(); + var ws1 = Guid.NewGuid(); + var ws2 = Guid.NewGuid(); + + _applicationProvider.GetFormVersionIdsAsync("Onboarding").Returns(new List { formVersionId }); + + _worksheetAppService.GetListByCorrelationAsync(formVersionId, "FormVersion").Returns(new List + { + new() { + Id = ws1, + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, + new CustomFieldDto { Key = "ministry", Label = "Ministry", Order = 1, Enabled = true } + ]} + ] + }, + new() { + Id = ws2, + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true }, // duplicate + new CustomFieldDto { Key = "hidden", Label = "Hidden", Order = 1, Enabled = false }, // disabled + new CustomFieldDto { Key = "email", Label = "Email", Order = 2, Enabled = true } + ]} + ] + } + }); + + var result = await _appService.GetColumnSchemaAsync(); + + result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ministry", "email"]); + } + + [Fact] + public async Task GetColumnSchemaAsync_ReturnsColumns_WithNoApplicationsSubmittedYet() + { + var formVersionId = Guid.NewGuid(); + + _applicationProvider.GetFormVersionIdsAsync("Onboarding").Returns(new List { formVersionId }); + + _worksheetAppService.GetListByCorrelationAsync(formVersionId, "FormVersion").Returns(new List + { + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true } + ]} + ] + } + }); + + var result = await _appService.GetColumnSchemaAsync(); + + result.Columns!.Select(c => c.Key).ShouldBe(["branch"]); + } + + [Fact] + public async Task GetColumnSchemaAsync_CombinesFieldsAcrossFormVersions_ByKeyEvenWithDifferentLabel() + { + var oldFormVersionId = Guid.NewGuid(); + var newFormVersionId = Guid.NewGuid(); + + // Both versions are published and mapped — GetFormVersionIdsAsync now returns every + // published version, not just the latest, so both contribute columns here. + _applicationProvider.GetFormVersionIdsAsync("Onboarding") + .Returns(new List { oldFormVersionId, newFormVersionId }); + + _worksheetAppService.GetListByCorrelationAsync(oldFormVersionId, "FormVersion").Returns(new List + { + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch (old wording)", Order = 0, Enabled = true } + ]} + ] + } + }); + _worksheetAppService.GetListByCorrelationAsync(newFormVersionId, "FormVersion").Returns(new List + { + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch (new wording)", Order = 0, Enabled = true }, + new CustomFieldDto { Key = "ministry", Label = "Ministry", Order = 1, Enabled = true } + ]} + ] + } + }); + + var result = await _appService.GetColumnSchemaAsync(); + + // Same key across versions collapses to one column — the first version encountered wins the label. + result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ministry"]); + result.Columns!.First(c => c.Key == "branch").Label.ShouldBe("Branch (old wording)"); + } + + [Fact] + public async Task GetColumnSchemaAsync_AppendsMappedCoreFieldColumns_AfterWorksheetColumns() + { + var formVersionId = Guid.NewGuid(); + + _applicationProvider.GetFormVersionIdsAsync("Onboarding").Returns(new List { formVersionId }); + _worksheetAppService.GetListByCorrelationAsync(formVersionId, "FormVersion").Returns(new List + { + new() { + Id = Guid.NewGuid(), + Sections = [ + new WorksheetSectionDto { Order = 0, Fields = [ + new CustomFieldDto { Key = "branch", Label = "Branch", Order = 0, Enabled = true } + ]} + ] + } + }); + _applicationProvider.GetMappedCoreFieldColumnsAsync("Onboarding").Returns(new List + { + new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true }, + new() { Key = "branch", Label = "Branch", Type = "String", Selected = true } // duplicate key — should be deduped + }); + + var result = await _appService.GetColumnSchemaAsync(); + + result.Columns!.Select(c => c.Key).ShouldBe(["branch", "ProjectName"]); + } + + [Fact] + public async Task GetListAsync_MergesCoreFieldValues_IntoFields() + { + var appId = Guid.NewGuid(); + + _applicationProvider.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), "Onboarding", Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>()) + .Returns(new PagedResultDto(1, [ + new OnboardingApplicationRecord + { + Id = appId, + Category = "Onboarding", + CoreFieldValues = new Dictionary { ["ProjectName"] = "Bridge Repair" } + } + ])); + + var result = await _appService.GetListAsync(new OnboardingListRequestDto()); + + result.Items[0].Fields["ProjectName"].ShouldBe("Bridge Repair"); + } + + [Fact] + public async Task GetListAsync_SortByMappedCoreField_PassesThroughToProvider_NoInMemoryFetchAll() + { + _applicationProvider.GetMappedCoreFieldColumnsAsync("Onboarding").Returns(new List + { + new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true } + }); + + (int Skip, int Take, string Sorting)? captured = null; + _applicationProvider + .When(p => p.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>())) + .Do(call => captured = (call.ArgAt(0), call.ArgAt(1), call.ArgAt(2))); + + await _appService.GetListAsync(new OnboardingListRequestDto + { + Sorting = "fields.ProjectName asc", + SkipCount = 5, + MaxResultCount = 10 + }); + + // A core field is provider-handled: paging/sort go straight to SQL — no "fetch everything, + // sort in memory" fallback (which would show up as Skip=0, Take=int.MaxValue, Sorting=null). + captured.ShouldNotBeNull(); + captured!.Value.Skip.ShouldBe(5); + captured.Value.Take.ShouldBe(10); + captured.Value.Sorting.ShouldBe("ProjectName ASC"); + } + + [Fact] + public async Task GetListAsync_ColumnFilterOnMappedCoreField_RoutedAsStaticFilter_NotWorksheetScan() + { + _applicationProvider.GetMappedCoreFieldColumnsAsync("Onboarding").Returns(new List + { + new() { Key = "ProjectName", Label = "Project Name", Type = "String", Selected = true } + }); + + IReadOnlyList capturedStaticFilters = null; + IReadOnlyList capturedDynamicMatchIds = null; + _applicationProvider + .When(p => p.GetPagedListAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any>(), Arg.Any>(), Arg.Any>())) + .Do(call => + { + capturedStaticFilters = call.ArgAt>(6); + capturedDynamicMatchIds = call.ArgAt>(7); + }); + + await _appService.GetListAsync(new OnboardingListRequestDto + { + ColumnFilters = [new ColumnFilterDto { Name = "ProjectName", Value = "bridge" }] + }); + + capturedStaticFilters.ShouldNotBeNull(); + capturedStaticFilters!.ShouldContain(f => f.Name == "ProjectName" && f.Value == "bridge"); + // Worksheet match precomputation never ran for this filter. + capturedDynamicMatchIds.ShouldBeNull(); + } + + [Fact] + public async Task ValidateAsync_ExplicitTenantNameFieldKey_OverridesSavedMapping() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("explicitKey", "Brand New Co"), ("savedKey", "acme")) + }); + + // Simulates a previously saved column mapping pointing at a different (colliding) field. + _settingManager.GetOrNullAsync(OnboardingColumnConfigSettings.TenantNameFieldKey, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("savedKey"); + + var result = await _appService.ValidateAsync(id, tenantNameFieldKey: "explicitKey", superUsersFieldKey: null); + + result.Issues.ShouldNotContain(i => i.StartsWith("[Tenant Name]")); + } + + [Fact] + public async Task ValidateAsync_AggregatesFailuresFromAllStepsInOrder() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "acme"), ("su", "not-an-email")) + }); + + var result = await _appService.ValidateAsync(id, tenantNameFieldKey: "tn", superUsersFieldKey: "su"); + + result.IsValid.ShouldBeFalse(); + result.Issues.Count.ShouldBe(2); + result.Issues[0].ShouldStartWith("[Tenant Name]"); + result.Issues[1].ShouldStartWith("[Super Users]"); + } + + [Fact] + public async Task CreateTenantAsync_NoValidSuperUsers_ThrowsAndDoesNotCreateTenant() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "Brand New Co"), ("su", "not-an-email")) + }); + + await Should.ThrowAsync(() => _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su" + })); + + await _tenantAppService.DidNotReceive().CreateAsync(Arg.Any()); + } + + [Fact] + public async Task CreateTenantAsync_DuplicateTenantName_ThrowsAndDoesNotCreateTenant() + { + var id = Guid.NewGuid(); + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + // "acme" is seeded by the test host, so this collides even though super users resolve fine. + WorksheetInstanceFor(id, ("tn", "acme"), ("su", "first@example.com")) + }); + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + + await Should.ThrowAsync(() => _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su" + })); + + await _tenantAppService.DidNotReceive().CreateAsync(Arg.Any()); + await _applicationProvider.DidNotReceive().CloseApplicationAsync(Arg.Any()); + } + + [Fact] + public async Task CreateTenantAsync_ValidSuperUsers_CreatesTenantAndAssignsRemainingAsManagers() + { + var id = Guid.NewGuid(); + var newTenantId = Guid.NewGuid(); + + _applicationProvider.GetByIdAsync(id).Returns(new OnboardingApplicationRecord { Id = id, Category = "Onboarding" }); + _worksheetInstanceAppService.GetListByCorrelationIdsAsync(Arg.Any>(), ApplicationCorrelationProvider).Returns(new List + { + WorksheetInstanceFor(id, ("tn", "New Co"), ("su", "first@example.com,second@example.com"), ("branch", "North")) + }); + + _userLookup.FindUserGuidByEmailAsync("first@example.com").Returns("guid-1"); + _userLookup.FindUserGuidByEmailAsync("second@example.com").Returns("guid-2"); + + _tenantAppService.CreateAsync(Arg.Any()) + .Returns(new TenantDto { Id = newTenantId, Name = "New Co" }); + + await _appService.CreateTenantAsync(id, new CreateTenantInputDto + { + TenantNameFieldKey = "tn", + SuperUsersFieldKey = "su", + BranchFieldKey = "branch" + }); + + await _tenantAppService.Received(1).CreateAsync(Arg.Is(d => + d.Name == "New Co" && d.Branch == "North" && d.UserIdentifier == "guid-1")); + await _tenantAppService.Received(1).AssignManagerAsync(Arg.Is(d => + d.TenantId == newTenantId && d.UserIdentifier == "guid-2")); + await _applicationProvider.Received(1).CloseApplicationAsync(id); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs index 37dcc34065..96f8c73743 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/TenantAppService_Tests.cs @@ -4,8 +4,10 @@ using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Shouldly; +using Unity.TenantManagement.Application; using Unity.TenantManagement.Application.Contracts; using Volo.Abp; +using Volo.Abp.TenantManagement; using Xunit; namespace Unity.TenantManagement; @@ -19,7 +21,12 @@ protected override void AfterAddApplication(IServiceCollection services) // Create a Substitute and replace original one in Service Collection var tenantConnectionStringBuilder = Substitute.For(); - tenantConnectionStringBuilder.Build(Arg.Any()).Returns("acme test connection"); + tenantConnectionStringBuilder.GenerateCredentialsAsync() + .Returns(Task.FromResult(new TenantDbCredentials("T_ABC123", "T_ABC123", "XYZ789"))); + tenantConnectionStringBuilder.GenerateReadOnlyCredentials(Arg.Any()) + .Returns(new TenantDbCredentials("T_ABC123", "T_ABC123_readonly", "RO12345")); + tenantConnectionStringBuilder.Build(Arg.Any(), Arg.Any()) + .Returns("acme test connection"); services.AddSingleton(tenantConnectionStringBuilder); } @@ -75,6 +82,11 @@ public async Task CreateAsync() var tenant = await _tenantAppService.CreateAsync(new TenantCreateDto { Name = tenancyName }); tenant.Name.ShouldBe(tenancyName); tenant.Id.ShouldNotBe(Guid.Empty); + + var tenantRepository = GetRequiredService(); + var tenantInDb = await tenantRepository.GetAsync(tenant.Id, includeDetails: true); + tenantInDb.ConnectionStrings.ShouldContain(cs => cs.Name == UnityTenantManagementConsts.TenantConnectionStringName); + tenantInDb.ConnectionStrings.ShouldContain(cs => cs.Name == UnityTenantManagementConsts.TenantReadOnlyConnectionStringName); } [Fact] diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs new file mode 100644 index 0000000000..972c606df6 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Validation/OnboardingValidationStepsTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Reflection; +using System.Threading.Tasks; +using NSubstitute; +using Shouldly; +using Unity.TenantManagement; +using Volo.Abp.TenantManagement; +using Xunit; + +namespace Unity.TenantManagement.Validation; + +public class SuperUsersValidationStepTests +{ + private static OnboardingRequestDto RequestWithSuperUsers(string superUsers) => + new() { SuperUsers = superUsers }; + + [Fact] + public async Task ValidateAsync_NoEmailsParsed_ReturnsFailure() + { + var lookup = Substitute.For(); + var step = new SuperUsersValidationStep(lookup); + + var result = await step.ValidateAsync(RequestWithSuperUsers("not an email")); + + result.IsValid.ShouldBeFalse(); + await lookup.DidNotReceive().FindUserGuidByEmailAsync(Arg.Any()); + } + + [Fact] + public async Task ValidateAsync_AnyParsedEmailResolves_ReturnsSuccess() + { + var lookup = Substitute.For(); + lookup.FindUserGuidByEmailAsync("first@example.com").Returns((string)null); + lookup.FindUserGuidByEmailAsync("second@example.com").Returns("guid-123"); + var step = new SuperUsersValidationStep(lookup); + + var result = await step.ValidateAsync(RequestWithSuperUsers("first@example.com; second@example.com")); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public async Task ValidateAsync_NoneOfTheParsedEmailsResolve_ReturnsFailure() + { + var lookup = Substitute.For(); + lookup.FindUserGuidByEmailAsync(Arg.Any()).Returns((string)null); + var step = new SuperUsersValidationStep(lookup); + + var result = await step.ValidateAsync(RequestWithSuperUsers("first@example.com,second@example.com")); + + result.IsValid.ShouldBeFalse(); + result.Issue.ShouldNotBeNullOrEmpty(); + } + + [Theory] + [InlineData("a@example.com,b@example.com", new[] { "a@example.com", "b@example.com" })] + [InlineData("a@example.com;b@example.com", new[] { "a@example.com", "b@example.com" })] + [InlineData("a@example.com|b@example.com", new[] { "a@example.com", "b@example.com" })] + [InlineData(" a@example.com , not-an-email , b@example.com ", new[] { "a@example.com", "b@example.com" })] + public void ParseEmails_HandlesDelimitersAndDropsNonEmailTokens(string input, string[] expected) + { + var result = SuperUsersValidationStep.ParseEmails(input); + + result.ShouldBe(expected); + } + + [Fact] + public void ParseEmails_ExtractsEmailsFromFormioDataGridJson() + { + const string dataGridJson = """ + { + "rows": [ + { + "cells": [ + { "key": "s03_SuperUserName", "value": "Kingsley Shacklebolt" }, + { "key": "s03_SuperUserEmail", "value": "kingsley.shacklebolt@gov.bc.ca" }, + { "key": "s03_SuperUserTitle", "value": "Minister for Magic" } + ] + }, + { + "cells": [ + { "key": "s03_SuperUserName", "value": "Minerva McGonagall" }, + { "key": "s03_SuperUserEmail", "value": "m.mcgonagall@hogwarts.ac.uk" }, + { "key": "s03_SuperUserTitle", "value": "External Liaison Officer" } + ] + } + ] + } + """; + + var result = SuperUsersValidationStep.ParseEmails(dataGridJson); + + result.ShouldBe(["kingsley.shacklebolt@gov.bc.ca", "m.mcgonagall@hogwarts.ac.uk"]); + } + + [Fact] + public void ParseEmails_DataGridRowsWithoutEmailColumn_ReturnsEmpty() + { + const string dataGridJson = """ + { + "rows": [ + { "cells": [ { "key": "s03_SuperUserName", "value": "Kingsley Shacklebolt" } ] } + ] + } + """; + + var result = SuperUsersValidationStep.ParseEmails(dataGridJson); + + result.ShouldBeEmpty(); + } +} + +public class TenantNameUniquenessStepTests +{ + private static OnboardingRequestDto RequestWithTenantName(string tenantName) => + new() { TenantName = tenantName }; + + // Tenant's (Guid, string, string) constructor is internal to the ABP assembly; reflection is the + // only way to build a real instance here, since this test only needs a non-null "found" result. + private static Tenant NewTenant(string name) => + (Tenant)typeof(Tenant) + .GetConstructor(BindingFlags.Instance | BindingFlags.NonPublic, [typeof(Guid), typeof(string), typeof(string)])! + .Invoke([Guid.NewGuid(), name, name.ToUpperInvariant()]); + + [Fact] + public async Task ValidateAsync_BlankTenantName_ReturnsFailureWithoutQueryingRepository() + { + var tenantRepository = Substitute.For(); + var step = new TenantNameUniquenessStep(tenantRepository); + + var result = await step.ValidateAsync(RequestWithTenantName(" ")); + + result.IsValid.ShouldBeFalse(); + await tenantRepository.DidNotReceive().FindByNameAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ValidateAsync_NameAlreadyExists_ReturnsFailure() + { + var tenantRepository = Substitute.For(); + var existing = NewTenant("Acme"); + tenantRepository.FindByNameAsync("ACME", Arg.Any(), Arg.Any()) + .Returns(existing); + var step = new TenantNameUniquenessStep(tenantRepository); + + var result = await step.ValidateAsync(RequestWithTenantName("Acme")); + + result.IsValid.ShouldBeFalse(); + result.Issue.ShouldContain("Acme"); + } + + [Fact] + public async Task ValidateAsync_NameIsUnique_ReturnsSuccess() + { + var tenantRepository = Substitute.For(); + tenantRepository.FindByNameAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((Tenant)null); + var step = new TenantNameUniquenessStep(tenantRepository); + + var result = await step.ValidateAsync(RequestWithTenantName("Brand New Co")); + + result.IsValid.ShouldBeTrue(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj index 6561e399e8..75e6846d56 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.EntityFrameworkCore.Tests/Unity.TenantManagement.EntityFrameworkCore.Tests.csproj @@ -17,6 +17,8 @@ + + diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs index ac4c3a644e..836c089f1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalScriptContributor.cs @@ -48,7 +48,8 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files.Add("/themes/ux2/layout.js"); context.Files.Add("/themes/ux2/plugins/filterRow.js"); context.Files.Add("/themes/ux2/plugins/scrollResize.js"); - context.Files.Add("/themes/ux2/plugins/colvisAlpha.js"); + context.Files.Add("/themes/ux2/plugins/colvisAlpha.js"); + context.Files.Add("/themes/ux2/plugins/tableContextMenu.js"); context.Files.Add("/themes/ux2/table-utils.js"); context.Files.Add("/themes/ux2/json-editor.js"); context.Files.Add("/js/DateUtils.js"); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs index a1beefb47f..8564213d1f 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Bundling/UnityThemeUX2GlobalStyleContributor.cs @@ -12,6 +12,7 @@ public override void ConfigureBundle(BundleConfigurationContext context) context.Files.Add("/themes/ux2/fluenticons.min.css"); context.Files.Add("/themes/ux2/layout.css"); context.Files.Add("/themes/ux2/unity-styles.css"); + context.Files.Add("/themes/ux2/plugins/tableContextMenu.css"); context.Files.Add("/themes/ux2/json-editor.css"); context.Files.AddIfNotContains("/libs/datatables.net-bs5/css/dataTables.bootstrap5.min.css"); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml index 66fe02aee6..cb0780c51b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Empty.cshtml @@ -64,6 +64,8 @@ @await Component.InvokeLayoutHookAsync(LayoutHooks.PageContent.Last, StandardLayouts.Empty)
    + + diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js index bfb5b0e8d5..02fac32e74 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/js/DateUtils.js @@ -1,29 +1,32 @@ -/** +/** * Date utility functions for Grant Manager application + * + * BC TIMEZONE NOTES (2026): + * - BC Pacific zones (Vancouver, Victoria, etc.) do NOT observe DST in 2026. + * They remain on PST (UTC-8) year-round. Use formatUtcToBcPacificDateTime / bcPstInputToUtcIso. + * - BC Mountain zones (Peace River / NE BC) DO observe DST: MST (UTC-7) in winter, + * MDT (UTC-6) in summer. Use formatUtcToBcMountainDateTime for those. */ const DateUtils = (function () { 'use strict'; + // BC PST is a fixed UTC-8 offset -- no DST in 2026. + const BC_PST_OFFSET_MS = -8 * 60 * 60 * 1000; + /** - * Formats a UTC date string to local date format + * Formats a UTC date string to the browser's local date format. + * NOTE: Uses the browser's system timezone. For BC PST (no DST) use formatUtcToBcPacificDateTime. * @param {string|Date} dateUtc - The UTC date to format * @param {string} type - The type of formatting (for DataTables compatibility) * @param {object} options - Additional formatting options - * @returns {string|number|null} Formatted date string or timestamp for sorting, null if input is invalid + * @returns {string} Formatted date string, or numeric timestamp string for sorting, empty string if input is invalid */ function formatUtcDateToLocal(dateUtc, type, options) { - if (!dateUtc) { - return null; - } - - const date = new Date(dateUtc); - - // Required for DataTables sorting & filtering if (type === 'sort' || type === 'type') { - return date.getTime(); + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; } - - return date.toLocaleDateString( + if (!dateUtc) return ''; + return new Date(dateUtc).toLocaleDateString( abp.localization.currentCulture.name, { year: 'numeric', @@ -34,6 +37,103 @@ const DateUtils = (function () { ); } + /** + * Formats a UTC date/time string as a BC Pacific date (date only). + * BC PST is fixed at UTC-8 -- no DST adjustment in 2026. + * @param {string|Date} dateUtc + * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) + * @param {object} options - Additional Intl.DateTimeFormat options + */ + function formatUtcToBcPacificDate(dateUtc, type, options) { + if (type === 'sort' || type === 'type') { + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; + } + if (!dateUtc) return ''; + return new Date(dateUtc).toLocaleDateString(abp.localization.currentCulture.name, { + timeZone: 'Etc/GMT+8', + year: 'numeric', + month: '2-digit', + day: '2-digit', + ...options + }); + } + + /** + * Formats a UTC date/time string as a BC Pacific date+time string with "PST" label. + * BC PST is fixed at UTC-8 -- no DST adjustment in 2026. + * @param {string|Date} dateUtc + * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) + */ + function formatUtcToBcPacificDateTime(dateUtc, type) { + if (type === 'sort' || type === 'type') { + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; + } + if (!dateUtc) return ''; + const formatted = new Date(dateUtc).toLocaleString(abp.localization.currentCulture.name, { + timeZone: 'Etc/GMT+8', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: 'numeric', + minute: '2-digit' + }); + return formatted + ' PST'; + } + + /** + * Formats a UTC date/time string for the BC Mountain timezone (Peace River / NE BC). + * Mountain Time observes DST in 2026: MST (UTC-7) in winter, MDT (UTC-6) in summer. + * @param {string|Date} dateUtc + * @param {string} type - DataTables type ('sort'|'type' returns numeric timestamp) + */ + function formatUtcToBcMountainDateTime(dateUtc, type) { + if (type === 'sort' || type === 'type') { + return dateUtc ? String(new Date(dateUtc).getTime()) : '0'; + } + if (!dateUtc) return ''; + // America/Edmonton follows MST/MDT -- DST applies in NE BC. + return new Date(dateUtc).toLocaleString(abp.localization.currentCulture.name, { + timeZone: 'America/Edmonton', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }); + } + + /** + * Converts a datetime-local input string (YYYY-MM-DDTHH:mm) to a UTC ISO string, + * treating the input as BC Pacific Standard Time (fixed UTC-8, no DST in 2026). + * Use this instead of new Date(localString).toISOString() which relies on the + * browser's potentially incorrect DST-adjusted timezone offset. + * @param {string} localDatetimeString - Value from a datetime-local input + * @returns {string} UTC ISO 8601 string, or empty string if input is empty/invalid + */ + function bcPstInputToUtcIso(localDatetimeString) { + if (!localDatetimeString) return ''; + // Append the BC PST fixed offset so Date parses it as UTC-8, not browser-local. + const withOffset = localDatetimeString + '-08:00'; + const date = new Date(withOffset); + return Number.isNaN(date.getTime()) ? '' : date.toISOString(); + } + + /** + * Converts a UTC timestamp (ms since epoch) to a datetime-local string (YYYY-MM-DDTHH:mm) + * in BC Pacific Standard Time (fixed UTC-8, no DST in 2026). + * Use this to populate datetime-local inputs with the correct BC PST time. + * @param {number} utcMs - Milliseconds since Unix epoch + * @returns {string} datetime-local string in BC PST + */ + function utcMsToBcPstDatetimeLocal(utcMs) { + // Shift UTC ms by -8h to get BC PST, then read UTC getters (which now represent PST). + const shifted = new Date(utcMs + BC_PST_OFFSET_MS); + const pad = n => String(n).padStart(2, '0'); + return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}` + + `T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`; + } + /** * Formats a date-only UTC string without local timezone conversion. * Use this for date-only fields (Due Date, Decision Date, Project Start/End Date) @@ -60,6 +160,11 @@ const DateUtils = (function () { // Public API return { formatUtcDateToLocal: formatUtcDateToLocal, + formatUtcToBcPacificDate: formatUtcToBcPacificDate, + formatUtcToBcPacificDateTime: formatUtcToBcPacificDateTime, + formatUtcToBcMountainDateTime: formatUtcToBcMountainDateTime, + bcPstInputToUtcIso: bcPstInputToUtcIso, + utcMsToBcPstDatetimeLocal: utcMsToBcPstDatetimeLocal, formatDate: formatDate }; -})(); \ No newline at end of file +})(); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css index 6fb5270b48..10037a60d0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/layout.css @@ -31,24 +31,25 @@ body { white-space: nowrap; text-overflow: ellipsis; font-weight: 700; + justify-content: center; } - .btn i { - font-size: 1.25rem; - } +.btn i { + font-size: 1.25rem; +} - .btn i:first-child { - margin-right: 0.375rem; - } +.btn i:first-child { + margin-right: 0.375rem; +} - .btn i:last-child { - margin-left: 0.375rem; - } +.btn i:last-child { + margin-left: 0.375rem; +} - .btn:hover { - box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.18); - border-color: var(--bc-colors-blue-primary); - } +.btn:hover { + box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.18); + border-color: var(--bc-colors-blue-primary); +} .btn-light { --bs-btn-color: var(--bc-colors-grey-text-500); @@ -137,10 +138,6 @@ div.dt-container.dt-scroll-resize { overflow-y: visible; } -.dt-container .dt-scroll-head { - min-height: 44px; -} - .dt-container .dt-scroll-head table { margin-top: 0px !important; } @@ -348,15 +345,15 @@ ul.pagination { padding-bottom: 0.4rem } - .navbar .dropdown-menu a { - font-size: 1rem; - padding: 10px 15px; - display: block; - min-width: 210px; - text-align: left; - border-radius: 0.25rem; - min-height: 44px; - } +.navbar .dropdown-menu a { + font-size: 1rem; + padding: 10px 15px; + display: block; + min-width: 210px; + text-align: left; + border-radius: 0.25rem; + min-height: 44px; +} .navbar .dropdown-submenu a::after { transform: rotate(-90deg); diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.css b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.css new file mode 100644 index 0000000000..b018118eb9 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.css @@ -0,0 +1,54 @@ +/* DataTable Context Menu Styling */ + +#dt-context-menu { + position: fixed; + min-width: 200px; + max-width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + overflow-y: auto; + padding: 0.5rem; + margin: 0; + list-style: none; + background-color: var(--bs-body-bg, #fff); + border: var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6); + border-radius: var(--bs-border-radius, 0.5rem); + box-shadow: var(--bs-box-shadow, 0 0.5rem 1rem rgba(0, 0, 0, 0.15)); + z-index: 10000; +} + +.dt-context-menu-item { + padding: 0; + margin: 0; +} + +.dt-context-menu-link { + display: block; + padding: 0.5rem 1rem; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + user-select: none; + font-size: 0.875rem; + border: 2px solid transparent; + border-radius: var(--bs-border-radius, 0.5rem); + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out, border-color 0.15s ease-in-out; +} + +.dt-context-menu-link:hover, +.dt-context-menu-link:focus-visible { + color: var(--bc-colors-blue-primary, #2E5DD7); + outline: none; + border: 2px solid var(--bc-colors-blue-primary, #2E5DD7); +} + +.dt-context-menu-link:active { + color: var(--bs-body-bg, #fff); + background-color: var(--bc-colors-blue-primary, #2E5DD7); +} + +.dt-context-menu-separator { + height: 0; + margin: 0.5rem 0; + padding: 0; + border-top: var(--bs-border-width, 1px) solid var(--bs-border-color, #dee2e6); +} diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.js b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.js new file mode 100644 index 0000000000..7325d43fc2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/wwwroot/themes/ux2/plugins/tableContextMenu.js @@ -0,0 +1,593 @@ +(function ($) { + 'use strict'; + + // Constants + const MENU_Z_INDEX = 10000; + const MENU_VIEWPORT_PADDING = 8; + const OFFSCREEN_OFFSET = '-9999px'; + const MENU_ID = 'dt-context-menu'; + + function getTableSettings(dtApi) { + return dtApi?.settings?.()?.[0] ?? null; + } + + function getFilterRowPlugin(dtApi) { + return getTableSettings(dtApi)?._filterRow ?? null; + } + + function getFilterRowElement(dtApi) { + return getFilterRowPlugin(dtApi)?.dom?.filterRow ?? $(); + } + + function getFilterInputs(dtApi) { + return getFilterRowElement(dtApi).find('input.custom-filter-input'); + } + + function getButtonsForTable(dtApi) { + return $(dtApi?.buttons?.().nodes?.() ?? []); + } + + function getFilterButton(dtApi) { + // Try to find filter button through table's button API first + const $tableButtons = getButtonsForTable(dtApi); + if ($tableButtons.length > 0) { + const $filterFromAPI = $tableButtons.filter('[id="btn-toggle-filter"]'); + if ($filterFromAPI.length > 0) { + return $filterFromAPI; + } + } + + // Fallback: search the table's scope or page-wide + const $scopeRoot = getScopeRoot(dtApi); + const $filterScoped = $scopeRoot.find('#btn-toggle-filter'); + if ($filterScoped.length > 0) { + return $filterScoped; + } + + // Final fallback: page-wide search + return $('#btn-toggle-filter'); + } + + function getScopeRoot(dtApi) { + const $container = $(dtApi?.table?.().container?.() ?? []); + return $container.closest('.tab-pane, .modal, .card, .content, body').first(); + } + + function findScopedElements(dtApi, selector) { + const $scopeRoot = getScopeRoot(dtApi); + const $scopedMatches = $scopeRoot.find(selector); + return $scopedMatches.length > 0 ? $scopedMatches : $(selector); + } + + function getMenuContainer() { + return $('#' + MENU_ID); + } + + function getMenuItems($menuContainer) { + return $menuContainer.find('.dt-context-menu-link:visible'); + } + + function focusMenuItem($menuContainer, index) { + const $items = getMenuItems($menuContainer); + if ($items.length === 0) { + return; + } + + const normalizedIndex = ((index % $items.length) + $items.length) % $items.length; + $items.attr('tabindex', '-1'); + + const $target = $items.eq(normalizedIndex); + $target.attr('tabindex', '0').trigger('focus'); + $menuContainer.data('activeIndex', normalizedIndex); + } + + function focusFirstMenuItem($menuContainer) { + focusMenuItem($menuContainer, 0); + } + + function rememberFocusTarget(element) { + const $menuContainer = getMenuContainer(); + $menuContainer.data('returnFocus', element ?? null); + } + + function restoreFocus() { + const $menuContainer = getMenuContainer(); + const returnFocus = $menuContainer.data('returnFocus'); + if (!returnFocus || typeof returnFocus.focus !== 'function') { + return; + } + + const hadTabIndex = returnFocus.hasAttribute('tabindex'); + if (!hadTabIndex) { + returnFocus.setAttribute('tabindex', '-1'); + } + + returnFocus.focus(); + + if (!hadTabIndex) { + returnFocus.addEventListener('blur', function cleanupFocusTarget() { + returnFocus.removeAttribute('tabindex'); + }, { once: true }); + } + } + + function handleMenuKeydown(e) { + const $menuContainer = getMenuContainer(); + const $items = getMenuItems($menuContainer); + const currentIndex = $items.index(globalThis.document.activeElement); + + if ($items.length === 0) { + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + focusMenuItem($menuContainer, currentIndex + 1); + break; + case 'ArrowUp': + e.preventDefault(); + focusMenuItem($menuContainer, currentIndex - 1); + break; + case 'Home': + e.preventDefault(); + focusMenuItem($menuContainer, 0); + break; + case 'End': + e.preventDefault(); + focusMenuItem($menuContainer, $items.length - 1); + break; + case 'Tab': + hideMenu(); + break; + case ' ': + if (currentIndex > -1) { + e.preventDefault(); + $items.eq(currentIndex).trigger('click'); + } + break; + case 'Escape': + e.preventDefault(); + hideMenu(); + break; + default: + break; + } + } + + function appendMenuAction($menuContainer, label, handler) { + $menuContainer.append( + $('
  • ').append( + $('') + .text(label) + .on('click', handler) + .on('mouseover', function () { + getMenuItems($menuContainer).blur(); // When mouse enters an item, remove focus from all items so :hover takes precedence + }) + ) + ); + } + + function positionMenu($menuContainer, clientX, clientY) { + $menuContainer.css({ + position: 'fixed', + display: 'block', + visibility: 'hidden', + zIndex: MENU_Z_INDEX + }); + + const menuWidth = $menuContainer.outerWidth() ?? 0; + const menuHeight = $menuContainer.outerHeight() ?? 0; + const maxLeft = Math.max(MENU_VIEWPORT_PADDING, globalThis.innerWidth - menuWidth - MENU_VIEWPORT_PADDING); + const maxTop = Math.max(MENU_VIEWPORT_PADDING, globalThis.innerHeight - menuHeight - MENU_VIEWPORT_PADDING); + const left = Math.min(Math.max(MENU_VIEWPORT_PADDING, clientX), maxLeft); + const top = Math.min(Math.max(MENU_VIEWPORT_PADDING, clientY), maxTop); + + $menuContainer.css({ + left: left + 'px', + top: top + 'px', + visibility: 'visible' + }); + } + + function showMenu($menuContainer, clientX, clientY, focusTarget) { + rememberFocusTarget(focusTarget); + positionMenu($menuContainer, clientX, clientY); + $menuContainer.attr('aria-hidden', 'false'); + focusFirstMenuItem($menuContainer); + } + + /** + * Handle filter column lookup and auto-population. + */ + function handleFilterAction(e, $cell, dtApi) { + e.preventDefault(); + hideMenu(); + + const cellText = ($cell.text() ?? '').trim(); + const filterRow = getFilterRowPlugin(dtApi); + + // Show the filter row if not already visible + filterRow?.show?.(); + + // Resolve the column name for the clicked cell + try { + const cellInfo = dtApi.cell($cell[0])?.index?.(); + if (!cellInfo) { + return; + } + + const colIdx = cellInfo.column; + const settings = dtApi.settings?.(); + const aoColumns = settings?.[0]?.aoColumns; + if (!aoColumns?.[colIdx]) { + return; + } + + const colName = aoColumns[colIdx].name; + if (!colName) { + return; + } + + // Find the filter input for this column and set the value + const filterRowElement = getFilterRowElement(dtApi); + const $input = filterRowElement.find('input.custom-filter-input').filter(function () { + return $(this).data('column-name') === colName; + }); + + if ($input.length > 0) { + $input.val(cellText).trigger('keyup'); + } + } catch (err) { + console.debug('Filter action error:', err); + } + } + + /** + * Handle clear filter action. + */ + function handleClearFilterAction(e, dtApi) { + e.preventDefault(); + hideMenu(); + + const filterRow = getFilterRowPlugin(dtApi); + filterRow?.clearFilters?.(); + } + + /** + * Handle generic toolbar button click. + */ + function handleToolbarButtonClick(e, $btn) { + e.preventDefault(); + hideMenu(); + $btn?.[0]?.click?.(); + } + + /** + * Check if a button is enabled (not hidden/disabled). + */ + function isButtonEnabled($btn) { + return !$btn.hasClass('action-bar-btn-unavailable') + && !$btn.prop('disabled') + && !$btn.hasClass('d-none') + && !$btn.hasClass('dt-button-disabled'); + } + + /** + * Handle dismiss on outside click. + */ + function dismissClickHandler(e) { + if (!$(e.target).closest('#' + MENU_ID).length) { + hideMenu(); + } + } + + /** + * Handle dismiss on scroll. + */ + function dismissScrollHandler() { + hideMenu(); + } + + /** + * Handle row selection with callback. + */ + function handleRowSelection(dtApi, rowIndex, callback) { + requestAnimationFrame(function () { + const selectedRows = dtApi.rows({ selected: true }).indexes(); + if (!selectedRows.includes(rowIndex)) { + dtApi.rows({ selected: true }).deselect(); + dtApi.row(rowIndex).select(); + } + requestAnimationFrame(callback); + }); + } + + /** + * Hide the context menu and clean up event handlers. + */ + function hideMenu() { + const $menuContainer = getMenuContainer(); + if ($menuContainer.length > 0) { + $menuContainer + .hide() + .attr('aria-hidden', 'true') + .off('keydown.dt-context-menu') + .removeData('activeIndex'); + } + + $(document).off('click.dt-context-menu'); + $(globalThis).off('scroll.dt-context-menu resize.dt-context-menu'); + + restoreFocus(); + } + + /** + * Copy text to clipboard with fallback. + */ + function copyToClipboard(text) { + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text) + .then(() => { + abp.notify.success((abp.localization.getResource('GrantManager')('DataTable:ContextMenu:CopiedToClipboard') ?? 'Copied to clipboard'),'Success'); + }) + .catch(() => { + fallbackCopy(text); + }); + } else { + fallbackCopy(text); + } + } + + /** + * Fallback copy using textarea hack (legacy support). + */ + function fallbackCopy(text) { + const $textarea = $(' + + +
    +
    + + +
    + + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs index 97183d98f8..18a1e5edfc 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/ActionBar.cs @@ -26,6 +26,8 @@ public override void ConfigureBundle(BundleConfigurationContext context) .AddIfNotContains("/Views/Shared/Components/ActionBar/Default.css"); context.Files .AddIfNotContains("/Pages/BulkApprovals/ApproveApplicationsModal.css"); + context.Files + .AddIfNotContains("/Pages/BulkActions/BulkPublishApplications.css"); } } @@ -48,6 +50,8 @@ public override void ConfigureBundle(BundleConfigurationContext context) .AddIfNotContains("/libs/jquery-maskmoney/dist/jquery.maskMoney.min.js"); context.Files .AddIfNotContains("/Pages/BulkApprovals/ApproveApplicationsModal.js"); + context.Files + .AddIfNotContains("/Pages/BulkActions/BulkPublishApplications.js"); } } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml index 7aaf21086f..274ae5d1a7 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ActionBar/Default.cshtml @@ -79,6 +79,15 @@ text="@L["ApplicationList:ApproveButton"].Value" /> } + @if (await PermissionChecker.IsGrantedAsync(UnitySelector.Application.Status.BulkPublish)) + { + + } + @if(await PermissionChecker.IsGrantedAsync(UnitySelector.Application.Tags.Create) || await PermissionChecker.IsGrantedAsync(UnitySelector.Application.Tags.Delete)) { { selectedApplicationIds.push(data.id); manageActionButtons(); @@ -302,7 +347,9 @@ $(function () { selectedApplicationIds = []; manageActionButtons(); }); + //#endregion Selection Events + //#region Action Button Click Events $('#assignApplication').on('click', function () { // Store application IDs in distributed cache to avoid URL length limits unity.grantManager.applications.applicationBulkActions @@ -344,7 +391,9 @@ $(function () { '/GrantApplications/Details?ApplicationId=' + selectedApplicationIds[0]; }); + //#endregion Action Button Click Events + //#region Action Bar State let summaryWidgetManager = new abp.WidgetManager({ wrapper: '#summaryWidgetArea', filterCallback: function () { @@ -379,7 +428,9 @@ $(function () { } } } + //#endregion Action Bar State + //#region Tags and Payments $('#tagApplication').on('click', function () { // Store application IDs in distributed cache to avoid URL length limits @@ -442,5 +493,6 @@ $(function () { abp.notify.success('The historical payment has been successfully recorded.', 'Historical Payment'); PubSub.publish("refresh_application_list"); }); + //#endregion Tags and Payments }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js index 65bf1e705a..b4c3ff5674 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicantHistory/Default.js @@ -76,12 +76,12 @@ $(function () { let $editBtn = $(' + + +
    Workflow Settings
    @@ -101,32 +126,5 @@
    - - -
    -
    -
    - - - -
    -
    -
    diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css index 1388466ab7..4a9ddf92ef 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ApplicationFormConfigWidget/Default.css @@ -1,4 +1,8 @@ -.configuration-warning { +#otherConfigForm .form-select { + width: 400px !important; +} + +.configuration-warning { color: var(--lpx-danger); border-color: var(--lpx-danger); } 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 bc77bf1c35..d42cf25888 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 @@ -38,7 +38,8 @@ // Update checkboxes which are serialized if unchecked $(`#assessmentResultForm input:checkbox`).each(function () { - assessmentResultObj[this.name] = (this.checked).toString(); + let propertyName = this.name.includes('.') ? this.name.split('.').pop() : this.name; + assessmentResultObj[propertyName] = this.checked; }); 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 532ca7ebe5..c5c30a36c7 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 @@ -34,13 +34,13 @@ } - - - + + @@ -238,7 +238,7 @@ else
    - +
    @@ -248,7 +248,7 @@ else
    - +
    diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js index b627970d32..21934f97dd 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/AssessmentScoresWidget/Default.js @@ -686,17 +686,17 @@ function queueApplicationScoring(triggerButton = null) { const monitorScoring = () => globalThis.AIGenerationButtonState.monitor({ $button, originalHtml: existingHtml, - getStatus: () => unity.grantManager.grantApplications.grantApplication - .getAIGenerationStatus(applicationId, 'application-scoring'), - onComplete: () => PubSub.publish('refresh_assessment_scores', null), + getStatus: () => globalThis.AIGenerationApi.getStatus(applicationId, 'application-scoring'), + onComplete: () => { + PubSub.publish('refresh_assessment_scores', null); + }, onFailed: (request) => abp.message.error(request?.failureReason || 'AI scoring failed.') }); - unity.grantManager.grantApplications.grantApplication - .queueApplicationScoring(applicationId) + globalThis.AIGenerationApi.queueApplicationScoring(applicationId) .done(function (generationStatus) { const request = generationStatus?.generationRequest; - const status = globalThis.AIGenerationButtonState?.resolveStatus(request?.status) ?? ''; + const status = String(request?.status ?? '').trim(); if (status === 'Completed') { globalThis.AIGenerationButtonState?.restoreForCooldownCheck($button, existingHtml); @@ -704,7 +704,6 @@ function queueApplicationScoring(triggerButton = null) { PubSub.publish('refresh_assessment_scores', null); return; } - monitorScoring(); }) .fail(function () { @@ -716,3 +715,28 @@ function queueApplicationScoring(triggerButton = null) { globalThis.syncAIRateLimitButtons?.(); }); } + +$(function () { + // Static buttons + $(document).on('click', '#regenerateAiScoresheetBtn', function () { + queueApplicationScoring(); + }); + $(document).on('click', '#btn-expand-all', function () { + expandAllAccordions('assessment-scoresheet'); + }); + $(document).on('click', '#btn-collapse-all', function () { + collapseAllAccordions('assessment-scoresheet'); + }); + $(document).on('click', '#saveAssessmentScoresBtn', function () { + saveAssessmentScores(); + }); + + // Dynamically-generated section buttons (event delegation) + $(document).on('click', '[id^="scoresheet-section-save-"]', function () { + saveScoresSection($(this).data('form-id'), $(this).data('section-id')); + }); + $(document).on('click', '[id^="scoresheet-section-discard-"]', function () { + discardChangesScoresSection($(this).data('form-id'), $(this).data('section-id')); + }); + +}); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js index 8e4c7c6ed3..61f25e632a 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/ChefsAttachments/ChefsAttachments.js @@ -202,34 +202,45 @@ $(function () { ) .prop('disabled', true); - $.ajax({ - url: '/api/app/ai/generation/attachment-summary', - data: JSON.stringify({ - applicationId: applicationId, - attachmentIds: summaryAttachmentIds, - }), - contentType: 'application/json', - type: 'POST', - success: function () { - resetAttachmentSelection(); - chefsDataTable.ajax.reload(); - abp.notify.success('AI summaries generated successfully.'); - globalThis.AIGenerationButtonState?.restore($activeButton); - globalThis.refreshAIRateLimitState?.(); - $activeButton.html(existingHTML).prop('disabled', false); - }, - error: function (error) { + globalThis.AIGenerationApi.queueAttachmentSummary({ + applicationId: applicationId, + attachmentIds: summaryAttachmentIds, + }) + .done(function (generationStatus) { + globalThis.AIGenerationButtonState?.setGenerating($activeButton); + pollAttachmentSummaryGeneration(applicationId, $activeButton, existingHTML); + }) + .fail(function (error) { console.error('Error generating AI summaries:', error); abp.message.error('An error occurred while generating AI summaries. Please try again.'); globalThis.AIGenerationButtonState?.restore($activeButton); globalThis.refreshAIRateLimitState?.(); $activeButton.html(existingHTML).prop('disabled', false); setGenerateSummariesEnabled(); - }, - }); + }); }); } + function pollAttachmentSummaryGeneration(applicationId, $button, originalHtml) { + globalThis.AIGenerationButtonState.monitor({ + $button, + originalHtml: originalHtml ?? $button.html(), + getStatus: () => globalThis.AIGenerationApi.getStatus(applicationId, 'attachment-summary'), + onComplete: refreshAttachmentSummaryResults, + onPollFailed: (error) => { + console.warn('Failed to poll AI attachment summary status.', error); + abp.message.error(error?.message || 'AI attachment summary generation failed.'); + } + }); + } + + function refreshAttachmentSummaryResults() { + resetAttachmentSelection(); + chefsDataTable.ajax.reload(); + abp.notify.success('AI summaries generated successfully.'); + globalThis.refreshAIRateLimitState?.(); + } + // Toggle all AI summaries (only if feature is enabled) const $toggleAllAISummariesButton = $('#toggleAllAISummaries'); let allAISummariesExpanded = false; diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml index bf6acd2ef9..7cf334679f 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/CustomFields/Default.cshtml @@ -5,8 +5,29 @@ Layout = null; } +
    +
    +
    Scoresheet & Worksheets Configuration
    +
    +
    + + +
    +
    -
    +
    -
    +
    @@ -48,27 +69,6 @@
    }
    -
    -
    -
    -
    - - - -
    -
    -
    -
    Assessment Info:
    diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.cshtml index e43cb89266..e47847b901 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/Default.cshtml @@ -1,15 +1,23 @@ @using Unity.GrantManager.Localization; @using Microsoft.Extensions.Localization; @using Unity.GrantManager.Web.Views.Shared.Components.ApplicationActionWidget; +@using Unity.GrantManager.Web.Views.Shared.Components.DetailsActionBar; @inject IStringLocalizer L +@model DetailsActionBarViewModel
    @await Component.InvokeAsync(typeof(ApplicationActionWidget), new { applicationId = @Model.ApplicationId })
    - @* Items are included here through the Review List JS*@ +
    { + if (!result.isConfirmed) { + return; + } + + let nextPublishedState = !isPublished; + + unity.grantManager.grantApplications.grantApplication + .updateExternalStatusVisibility(selectedApplicationIds, nextPublishedState) + .then(function () { + $publishButton.attr('data-is-published', nextPublishedState.toString()); + $publishButton.text(nextPublishedState + ? l('DetailsActionBar:UnpublishButton') + : l('DetailsActionBar:PublishButton')); + + abp.notify.success(nextPublishedState + ? l('DetailsActionBar:PublishStatusUpdatedToast') + : l('DetailsActionBar:UnpublishStatusUpdatedToast')); + + let canPublishStatus = abp.auth.isGranted("Unity.GrantManager.ApplicationManagement.Application.Status.Publish"); + let canUnpublishStatus = abp.auth.isGranted("Unity.GrantManager.ApplicationManagement.Application.Status.Unpublish"); + let canUpdateExternalStatusVisibility = (!nextPublishedState && canPublishStatus) || (nextPublishedState && canUnpublishStatus); + $('#togglePublishStatusBtn').prop('disabled', !canUpdateExternalStatusVisibility); + + PubSub.publish('application_status_changed', nextPublishedState ? 'Publish' : 'Unpublish'); + PubSub.publish('refresh_detail_panel_summary'); + }) + .catch(function (error) { + abp.notify.error(l('DetailsActionBar:PublishStatusUpdateFailed')); + console.error('Error updating publish status:', error); + }); + }); + }); + + function getPublishConfirmationDetails(isPublished) { + return { + title: l('DetailsActionBar:ConfirmActionTitle'), + text: isPublished + ? l('DetailsActionBar:UnpublishConfirmationText') + : l('DetailsActionBar:PublishConfirmationText'), + confirmButtonText: l('DetailsActionBar:ConfirmButton') + }; + } }); diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs index 03c0ece84f..dd99427fc3 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBar.cs @@ -1,5 +1,9 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using System; +using System.Threading.Tasks; +using Unity.GrantManager.GrantApplications; +using Unity.Modules.Shared; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; @@ -7,14 +11,26 @@ namespace Unity.GrantManager.Web.Views.Shared.Components.DetailsActionBar; [Widget(ScriptFiles = new[] { "/Views/Shared/Components/DetailsActionBar/Default.js" , "/Pages/ApplicationTags/ApplicationTags.js" }, StyleFiles = new[] { "/Views/Shared/Components/ActionBar/Default.css" })] -public class DetailsActionBar : AbpViewComponent +public class DetailsActionBar( + IGrantApplicationAppService grantApplicationAppService, + IAuthorizationService authorizationService) : AbpViewComponent { [BindProperty] public Guid SelectedApplicationId { get; set; } - public IViewComponentResult Invoke(Guid applicationId) + public async Task InvokeAsync(Guid applicationId) { SelectedApplicationId = applicationId; - return View(); + + var application = await grantApplicationAppService.GetBasicAsync(SelectedApplicationId); + var canPublishStatus = await authorizationService.IsGrantedAnyAsync(UnitySelector.Application.Status.Publish); + var canUnpublishStatus = await authorizationService.IsGrantedAnyAsync(UnitySelector.Application.Status.Unpublish); + + return View(new DetailsActionBarViewModel + { + ApplicationId = SelectedApplicationId, + ExternalStatusVisibility = application.ExternalStatusVisibility, + CanUpdateExternalStatusVisibility = (!application.ExternalStatusVisibility && canPublishStatus) || (application.ExternalStatusVisibility && canUnpublishStatus), + }); } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBarViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBarViewModel.cs new file mode 100644 index 0000000000..9bb87e8c81 --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/DetailsActionBar/DetailsActionBarViewModel.cs @@ -0,0 +1,10 @@ +using System; + +namespace Unity.GrantManager.Web.Views.Shared.Components.DetailsActionBar; + +public class DetailsActionBarViewModel +{ + public Guid ApplicationId { get; set; } + public bool ExternalStatusVisibility { get; set; } + public bool CanUpdateExternalStatusVisibility { get; set; } +} diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml index 1e13c3f457..9f1514583c 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/Default.cshtml @@ -1,3 +1,5 @@ -
    - +@model Unity.GrantManager.Web.Views.Shared.Components.EmailHistoryWidget.EmailHistoryWidgetViewModel +
    +
    \ No newline at end of file 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 6b1bc28021..993cbcb86f 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 @@ -1,23 +1,31 @@ $(function () { - let inputAction = function() { + let inputAction = function () { const urlParams = new URL(window.location.toLocaleString()).searchParams; const applicationId = urlParams.get('ApplicationId'); return applicationId; } let responseCallback = function (result) { + const normalizedResult = (result || []).map(item => ({ + ...item, + templateName: resolveTemplateName(item) + })); + if (result) { setTimeout(function () { - PubSub.publish('update_application_emails_count', { itemCount: result.length }); + PubSub.publish('update_application_emails_count', { itemCount: normalizedResult.length }); }, 10); } return { - data: result + data: normalizedResult }; }; + const enableEmailDelay = $('#EmailHistoryTable').data('enable-email-delay') === true + || $('#EmailHistoryTable').data('enable-email-delay') === 'true'; + let emailHistoryDataTable = $('#EmailHistoryTable').DataTable( abp.libs.datatables.normalizeConfiguration({ serverSide: false, @@ -65,8 +73,7 @@ year: "numeric", month: "numeric", hour: "numeric", - minute: "numeric", - second: "numeric" + minute: "numeric" }) : '—'; } }, @@ -83,8 +90,7 @@ year: "numeric", month: "numeric", hour: "numeric", - minute: "numeric", - second: "numeric" + minute: "numeric" }) : '—'; } }, @@ -92,39 +98,74 @@ title: 'Sent By', data: 'sentBy', className: 'data-table-header', - width: '16%', - render: function (data) { + width: enableEmailDelay ? '10%' : '16%', + render: function (data, type, full) { + if (full.scheduledNotificationId && full.scheduledNotificationId !== '00000000-0000-0000-0000-000000000000') { + return 'Automated Notification'; + } return data ? data.name + ' ' + data.surname : '—'; }, }, + { + title: 'Scheduled Send', + data: 'sendOnDateTime', + className: 'data-table-header text-center', + width: '10%', + visible: enableEmailDelay, + render: function (data, type) { + if (!data) return '—'; + return formatScheduledSendDateTimeUtcToPacific(data, type) || '—'; + } + }, { title: 'To Address', data: 'toAddress', - visible : false , + visible: false, className: 'data-table-header' }, { title: 'From Address', data: 'fromAddress', - visible : false , + visible: false, className: 'data-table-header' }, { title: 'Body', data: 'body', - visible : false , + visible: false, className: 'data-table-header' }, + { + title: 'Template Name', + data: 'templateName', + visible: false, + className: 'data-table-header' + }, + { + title: 'Scheduled Notification ID', + data: 'scheduledNotificationId', + visible: false, + className: 'data-table-header', + defaultContent: '' + }, { data: 'status', width: '8%', className: 'text-center', render: function (data, _, full, meta) { - if (data === 'Draft' && abp.auth.isGranted('Notifications.Email.Send')) { + // Show delete button for drafts + if (data === 'Draft' && abp.auth.isGranted('Notifications.Email.DeleteDraft')) { return generateDeleteButtonContent(full, meta.row); - } else { - return ''; } + // Show cancel button for scheduled sends that haven't passed yet + else if (full.sendOnDateTime && abp.auth.isGranted('Notifications.Email.CancelScheduled')) { + const sendOnDateTime = parseUtcDateTime(full.sendOnDateTime); + const now = luxon.DateTime.utc(); + if (sendOnDateTime && sendOnDateTime > now) { + return generateCancelScheduledButtonContent(full, meta.row); + } + } + return ''; }, orderable: false } @@ -136,6 +177,10 @@ return ``; } + + + + function rowFormat(d) { return '
    ' + d.body + '
    '; } @@ -158,11 +203,16 @@ emailHistoryDataTable.on('click', 'tr td', function (e) { let tr = e.target.closest('tr'); let row = emailHistoryDataTable.row(tr); - let column = emailHistoryDataTable.column( this ); + let column = emailHistoryDataTable.column(this); + + if (column.index() > 0 && column.index() < 4) { + const data = row.data(); + const normalizedSelectedRow = { + ...data, + templateName: resolveTemplateName(data) + }; - if(column.index() > 0 && column.index() < 4) { - let data = row.data(); - PubSub.publish('email_selected', data); + PubSub.publish('email_selected', normalizedSelectedRow); } }); @@ -175,6 +225,91 @@ }); }); +function resolveTemplateName(emailRow) { + const value = [ + emailRow?.templateName, + emailRow?.emailTemplateName, + emailRow?.template, + emailRow?.TemplateName, + emailRow?.EmailTemplateName, + emailRow?.Template + ].find(v => typeof v === 'string' && v.trim().length > 0); + + return (value || '').trim(); +} + +function parseUtcDateTime(value) { + if (!value) { + return null; + } + + const normalized = String(value).trim().replace(' ', 'T'); + const withUtcSuffix = /([zZ]|[+-]\d{2}:?\d{2})$/.test(normalized) + ? normalized + : `${normalized}Z`; + + const dateTime = luxon.DateTime.fromISO(withUtcSuffix, { zone: 'utc' }); + return dateTime.isValid ? dateTime : null; +} + +function formatScheduledSendDateTimeUtcToPacific(value, type) { + if (type !== 'display' && type !== 'filter') { + return value; + } + + const utcDateTime = parseUtcDateTime(value); + if (!utcDateTime) { + return '—'; + } + + return utcDateTime + .setZone('UTC-7') + .toLocaleString({ + day: 'numeric', + year: 'numeric', + month: 'numeric', + hour: 'numeric', + minute: 'numeric' + }); +} + +function generateCancelScheduledButtonContent(full, row) { + return ``; +} + +function cancelScheduledEmail(id, rowIndex) { + Swal.fire({ + title: "Cancel Scheduled Email", + text: "Are you sure you want to cancel this scheduled email?", + showCancelButton: true, + confirmButtonText: "Confirm", + customClass: { + confirmButton: 'btn btn-primary', + cancelButton: 'btn btn-secondary' + } + }).then((result) => { + if (result.isConfirmed) { + $.ajax({ + url: `/api/app/email-notification/${id}/email`, + type: "DELETE", + }) + .then(response => { + abp.notify.success('Scheduled email has been cancelled.', 'Cancel Scheduled Email'); + PubSub.publish('refresh_application_emails'); + PubSub.publish('scheduled_email_cancelled', { id: id }); + }) + .catch(error => { + console.error('There was a problem with the fetch operation:', error); + + // Extract error message from API response + const errorMessage = error?.responseJSON?.error?.message || 'Failed to cancel scheduled email. Please try again.'; + + abp.notify.error(errorMessage, 'Cancel Scheduled Email'); + }); + } + }); +} + function deleteDraftEmail(id, rowIndex) { Swal.fire({ title: "Delete Draft Email", @@ -191,15 +326,20 @@ function deleteDraftEmail(id, rowIndex) { { url: `/api/app/email-notification/${id}/email`, type: "DELETE", - }) - .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); - }); + }) + .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); + + // Extract error message from API response + const errorMessage = error?.responseJSON?.error?.message || 'Failed to delete draft email. Please try again.'; + + abp.notify.error(errorMessage, 'Delete Draft Email'); + }); } }); } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css index 41687251bb..c98d1ae801 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistory.css @@ -18,6 +18,8 @@ overflow: visible !important; width: 100%; box-sizing: border-box; + overflow-x: auto !important; + scrollbar-gutter: stable; } #EmailHistoryTable_wrapper .dt-scroll-head, diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs index 706c878735..e4653f23e2 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewComponent.cs @@ -1,19 +1,27 @@ using Microsoft.AspNetCore.Mvc; +using System; using System.Collections.Generic; +using System.Threading.Tasks; using Volo.Abp.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Volo.Abp.AspNetCore.Mvc.UI.Widgets; +using Volo.Abp.Settings; +using Unity.Notifications.Settings; namespace Unity.GrantManager.Web.Views.Shared.Components.EmailHistoryWidget; [Widget( ScriptTypes = new [] {typeof(EmailHistoryScriptBundleContributor)}, StyleTypes = new [] {typeof(EmailHistoryStyleBundleContributor)})] -public class EmailHistoryWidgetViewComponent : AbpViewComponent +public class EmailHistoryWidgetViewComponent(ISettingProvider settingProvider) : AbpViewComponent { - public IViewComponentResult Invoke() + public async Task InvokeAsync() { - return View(); + var enableEmailDelay = string.Equals( + await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.EnableEmailDelay), + "true", StringComparison.OrdinalIgnoreCase); + + return View(new EmailHistoryWidgetViewModel { EnableEmailDelay = enableEmailDelay }); } } diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewModel.cs b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewModel.cs new file mode 100644 index 0000000000..6c3fa2b21f --- /dev/null +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailHistoryWidget/EmailHistoryWidgetViewModel.cs @@ -0,0 +1,6 @@ +namespace Unity.GrantManager.Web.Views.Shared.Components.EmailHistoryWidget; + +public class EmailHistoryWidgetViewModel +{ + public bool EnableEmailDelay { get; set; } +} 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 3762c96b59..ce6c7ea1bb 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 @@ -12,6 +12,9 @@ Layout = null; var sendEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email.Send"); var emailFieldsetState = sendEmailGranted ? string.Empty : "disabled"; + var enableEmailDelay = Model.EnableEmailDelay; + var scheduleEmailGranted = await PermissionChecker.IsGrantedAsync("Notifications.Email.Schedule"); + var enableScheduleSend = enableEmailDelay && scheduleEmailGranted; }
    @@ -67,6 +70,7 @@ +
    Email Form @@ -77,115 +81,181 @@ - - - - - - - - + + + - - -
    - + +
    + +@section Scripts { + +} \ No newline at end of file diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.css b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.css index 2a3f2197b6..d4aae78553 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.css +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.css @@ -17,11 +17,138 @@ #applicationEmailsWidget .email-input { outline: 0; margin-right: 8px; - margin-left: 8px; + margin-left: 10px; margin-top: 2px; margin-bottom: 2px; + height: 36px; +} + +#applicationEmailsWidget input.form-control { + width: 99%; +} + +#applicationEmailsWidget .label-column-aligned { + display: flex; + align-items: flex-start; + height: 100%; + padding-bottom: 0px; + flex-direction: column; + justify-content: flex-end; +} + +#applicationEmailsWidget .col-1 { + width: 44px; +} + +#applicationEmailsWidget .col-auto { + flex: 0 0 auto; + width: auto; +} + +/* Uniform label width for To, Cc, Bcc fields */ +#applicationEmailsWidget .row.align-items-start > .col-auto, +#applicationEmailsWidget .row.align-items-center > .col-auto { + flex: 0 0 46px !important; + width: 46px !important; +} + +#applicationEmailsWidget .col { + flex: 1 0 0%; + width: 100%; +} + +#applicationEmailsWidget .email-to-container { + position: relative; +} + +#applicationEmailsWidget .email-to-container .email-input { + padding-right: 60px; +} + +#applicationEmailsWidget .email-bcc-button { + position: absolute; + right: 8px; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + white-space: nowrap; + z-index: 10; +} + +#applicationEmailsWidget #bcc-input-row { + display: none; +} + +#applicationEmailsWidget #bcc-input-row.show { + display: flex; +} + + +#applicationEmailsWidget #scheduled-delay-section #send-on-display:empty { + display: none; +} + +#applicationEmailsWidget #scheduled-delay-section #send-on-display:empty ~ #btn-clear-schedule { + display: none !important; +} + +#applicationEmailsWidget .email-bcc-button.hide { + display: none; +} + +#applicationEmailsWidget .from-field-container { + align-items: center; + display: flex; + height: 36px; + margin: 0px; + flex-grow: 1; + gap: 0.3rem; + position: relative; + width: 100%; + margin-top: 4px; } +#applicationEmailsWidget .from-field-container .btn-send-from { + position: absolute; + right: 0; + top: 0; + height: 100%; + padding: 0 12px !important; + white-space: nowrap; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; +} + +#applicationEmailsWidget .from-label { + font-size: 1rem !important; + font-weight: 500; + white-space: nowrap; + margin-left: 0px !important; + margin-bottom: 0; + color: var(--bc-colors-grey-text-300); + min-width: 40px; +} + +#applicationEmailsWidget .from-field-container .from-input { + min-width: 150px; + width: 100%; + height: 36px; + padding: 6px 8px !important; + position: relative; + flex-grow: 1; + padding-right: 75px !important; + display: block; + margin: 0px !important; +} + +#applicationEmailsWidget .from-field-container .mb-3 { + display: inline-block; + vertical-align: middle; + margin-top: 9px; + margin-bottom: 0px; + flex-grow: 1; +} #applicationEmailsWidget .email-form label { color: var(--bc-colors-grey-text-300); @@ -30,6 +157,40 @@ text-overflow: ellipsis; margin-left: 8px; margin-bottom: 0; + display: inline-block; +} + +/* Override margin-top for labels in row layouts (To, Cc, Bcc) */ +#applicationEmailsWidget .row.align-items-start label, +#applicationEmailsWidget .row.align-items-center label { + margin-top: 15px; + font-size: 14px; + font-weight: 500; +} + +#applicationEmailsWidget .form-label { + display: inline-block; + color: var(--bc-colors-grey-text-300, #666666); + font-size: 1rem !important; + font-family: 'BCSans', var(--bs-font-sans-serif) !important; + font-weight: var(--bs-body-font-weight, 400); + margin-bottom: 0 !important; + margin-top: 0 !important; +} + +/* Specific alignment fix for labels in horizontal layouts */ +#applicationEmailsWidget #scheduled-label-container { + display: none; + padding-top: 8px; /* Adjust this value to perfectly center label with the first line of input */ +} + +#applicationEmailsWidget #scheduled-label-container.show { + display: inline; +} + +/* Ensure the parent row aligns to the top to prevent shifting when errors appear */ +#applicationEmailsWidget .row.align-items-start { + align-items: flex-start !important; } #applicationEmailsWidget .email-input:hover { @@ -63,6 +224,61 @@ color: #b9b9b9 !important; } +/* Email template select styling */ +#applicationEmailsWidget #EmailTemplate { + margin-right: 8px; + margin-left: 8px; + margin-top: 2px; + margin-bottom: 2px; +} + +/* 1. Style the select itself when the empty value is selected */ +#applicationEmailsWidget #EmailTemplate:has(option[value=""]:checked) { + color: #b9b9b9 !important; +} + +/* 2. Style the placeholder option inside the dropdown list */ +#applicationEmailsWidget #EmailTemplate option[value=""] { + color: #b9b9b9 !important; +} + +/* 3. Ensure valid selections return to your default text color (e.g., dark grey/black) */ +#applicationEmailsWidget #EmailTemplate option:not([value=""]) { + color: #212529; +} + +#applicationEmailsWidget button { + margin-top: 0px; + margin-bottom: 0px; + height: 36px; + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + max-height: 36px; + padding-top: 0px; + padding-bottom: 0px; + box-sizing: border-box; +} + +#user-dropdown > button { + height: 36px; + min-height: 36px; + max-height: 36px; + display: flex; + align-items: center; + justify-content: center; + padding-top: 0px; + padding-bottom: 0px; + box-sizing: border-box; + margin-top: 0px; + margin-bottom: 0px; +} + +#applicationEmailsWidget .email-buttons-group { + display: flex; + align-items: center; +} #applicationEmailsWidget .email-btn { margin-top: 10px; @@ -80,6 +296,97 @@ box-shadow: -3px 3px 11px -3px rgba(33, 33, 33, .2); } +#applicationEmailsWidget .btn-send-close { + margin-top: 0px; + margin-bottom: 0px; +} + +/* Send dropdown button styling */ +#applicationEmailsWidget .btn-group .dropdown-toggle { + padding-left: 1px !important; + padding-right: 1px !important; + border-left: 1px solid rgba(255, 255, 255, 0.2) !important; + min-width: 20px; + width: 20px; + margin-left: 0px !important; +} + +/* When dropdown is not visible/enabled, apply right border radius to send button */ +#applicationEmailsWidget .btn-group:not(:has(.dropdown-toggle)) #btn-send-top { + border-top-right-radius: 3px !important; + border-bottom-right-radius: 3px !important; +} + +#applicationEmailsWidget .form-select { + height: 36px; +} + +#applicationEmailsWidget .btn-group .dropdown-toggle.show { + background-color: inherit !important; + color: inherit !important; + border-color: inherit !important; +} + +#applicationEmailsWidget .btn-group .dropdown-toggle::after { + display: none; +} + +#applicationEmailsWidget .btn-group .dropdown-toggle i { + font-size: 0.85rem; +} + +/* Dropdown menu positioning */ +#applicationEmailsWidget .btn-group .dropdown-menu { + left: 0 !important; + right: auto !important; + margin-top: 0.25rem !important; + transform: translate(0px, 36px) !important; +} + +.dropdown-menu .btn-send-menu, +.dropdown-menu .btn-schedule-send-menu { + display: block; + width: 100%; + text-align: left; +} + +/* Email buttons row - sticky at top */ +#applicationEmailsWidget #EmailForm .row:has(.email-buttons-container) { + position: sticky; + top: 0; + background-color: white; + z-index: 980; + padding-bottom: 0.8rem !important; +} + +#scheduled-display-container { + padding-bottom: 10px; +} + +/* Email buttons container */ +.email-buttons-container { + display: flex !important; + align-items: center; + gap: 0.5rem; + width: 100%; + overflow: visible; +} + +.email-buttons-container abp-modal-footer { + display: contents; +} + +.email-buttons-container .ms-auto { + display: flex; + align-items: center; + gap: 0.5rem; + overflow: visible; +} + +.email-buttons-container .dropdown-menu { + z-index: 1070; +} + #applicationEmailsWidget .email-lbl { display: block; margin-top: 0.25rem; @@ -109,7 +416,35 @@ } #applicationEmailsWidget .field-validation-error { + display: inline-block; margin-left: 8px; + white-space: nowrap; + font-size: 0.875rem; + color: #dc3545; +} + +#applicationEmailsWidget .input-validation-error { + border: 1px solid #dc3545 !important; + border-style: solid !important; + border-color: #dc3545 !important; + border-width: 1px !important; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 0.2rem rgba(220, 53, 69, 0.5) !important; +} + +#applicationEmailsWidget .input-validation-error:focus, +#applicationEmailsWidget input.input-validation-error:focus, +#applicationEmailsWidget textarea.input-validation-error:focus, +#applicationEmailsWidget select.input-validation-error:focus { + border: 1px solid #dc3545 !important; + border-color: #dc3545 !important; + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 0.2rem rgba(220, 53, 69, 0.5) !important; +} + +#applicationEmailsWidget .from-field-container .field-validation-error { + position: absolute; + top: 85%; + margin-left: 0; + display: inline-block; } #applicationEmailsWidget .confirmation-label { @@ -204,8 +539,6 @@ justify-content: center; } - - #EmailForm { display: none; } @@ -218,14 +551,13 @@ display: none; } -#applicationEmailsWidget .toast-top-center { +#applicationEmailsWidget .toast-top-center { top: 220px; margin: 0 auto; left: 50%; margin-left: -450px; } - #applicationEmailsWidget #modal-background.active, #applicationEmailsWidget #modal-content.active { display: block; @@ -242,7 +574,7 @@ border-radius: 50%; border: 5px solid #eaf5fe; border-right-color: #5597d4; - animation: rotateSpinner 800ms linear infinite; + animation: rotateSpinner 800ms linear infinite; } #applicationEmailsWidget .email-spinner-text { @@ -266,8 +598,473 @@ background-color: white !important; } +#schedule-modal-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 1060; +} + +#schedule-send-modal { + display: none !important; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 600px; + max-width: 90vw; + max-height: 90vh; + background: #fff; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + z-index: 1061; + padding: 24px; + flex-direction: column; + overflow: auto; +} + +#schedule-send-modal.active { + display: flex !important; +} + +/* Header */ +.schedule-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 1px solid #dee2e6; +} + +.schedule-modal-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: #212529; +} + +.btn-close-modal { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #6c757d; + padding: 0; + width: auto; + height: auto; +} + +.btn-close-modal:hover { + color: #212529; +} + +/* Body Layout */ +.schedule-modal-body { + display: flex; + gap: 2rem; + flex: 1; + min-height: 350px; +} + +/* Calendar Section */ +.schedule-calendar-section { + flex: 1; + min-width: 300px; +} + +.calendar-nav { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.calendar-month-year { + font-size: 1.1rem; + font-weight: 600; + color: #212529; + min-width: 150px; + text-align: center; +} + +.calendar-nav button { + width: 32px; + height: 32px; + padding: 0; + font-size: 1rem; +} + +/* Calendar Grid */ +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.calendar-day-header { + text-align: center; + font-weight: 600; + font-size: 0.85rem; + color: #6c757d; + padding: 8px 0; + border-bottom: 1px solid #e9ecef; + margin-bottom: 4px; + grid-column: span 1; +} + +.calendar-day { + text-align: center; + padding: 8px; + cursor: pointer; + border-radius: 4px; + font-size: 0.9rem; + transition: all 0.2s ease; + border: 2px solid transparent; +} + +.calendar-day.past { + color: #adb5bd; + cursor: not-allowed; + background: transparent; +} + +.calendar-day.today { + background: #0d6efd; + color: #fff; + border-radius: 50%; + font-weight: 600; +} + +.calendar-day.selected { + border-color: #0d6efd; + font-weight: 600; + background: #e7f1ff; +} + +.calendar-day:not(.past):hover { + background: #f8f9fa; + border-color: #0d6efd; +} + +.calendar-day.today.selected { + border-color: #fff; + background: #0d6efd; +} + +/* Inputs Section */ +.schedule-inputs-section { + flex: 1; + min-width: 240px; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +#schedule-date-input { + width: 100%; + box-sizing: border-box; +} + +.schedule-input-group { + display: flex; + flex-direction: column; +} + +.schedule-input-group label { + font-size: 0.95rem; + font-weight: 500; + margin-bottom: 0.5rem; + color: #495057; +} + +#schedule-date-input, +#schedule-time-select { + padding: 0.5rem 0.75rem; + font-size: 0.95rem; + border: 1px solid #ced4da; + border-radius: 0.25rem; +} + +#schedule-date-input:focus, +#schedule-time-select:focus { + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); + outline: none; +} + +.input-group-text { + background: #f8f9fa; + border-color: #ced4da; + color: #495057; +} + +/* Footer */ +.schedule-modal-footer { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid #dee2e6; +} + +.schedule-modal-footer button { + min-width: 100px; +} + +#schedule-modal-validation { + font-size: 0.85rem; +} + +/* Responsive */ +@media (max-width: 768px) { + #schedule-send-modal { + width: 95vw; + max-height: 95vh; + padding: 16px; + } + + .schedule-modal-body { + flex-direction: column; + gap: 1rem; + min-height: auto; + } + + .schedule-calendar-section, + .schedule-inputs-section { + min-width: auto; + width: 100%; + } +} + +#btn-picker-ok { + display: none; +} + +#send-on-display { + font-size: 0.9em; +} + +#btn-clear-schedule { + display: none; +} + +#delay-datetime-validation { + display: none; + font-size: 0.85em; +} + +.send-date-hint { + font-size: 0.85em; +} + +input#EmailFrom.input-validation-error { + margin-bottom: -20px !important; +} + + + +.mt-14 { + margin-top: 14px !important; +} + @keyframes rotateSpinner { to { transform: rotate(360deg); } -} \ No newline at end of file +} + +/* ============================================ + TinyMCE Email Composer Styling + ============================================ */ + +/* Hide TinyMCE toolbar completely */ +.tox-tinymce:has(#EmailBody) .tox-toolbar__primary { + display: none !important; +} + +/* Container for template label and menu buttons */ +.tinymce-template-label-top-right { + position: static !important; + font-size: 13px; + color: #444; + user-select: none; + display: flex; + gap: 5px; + align-items: center; + justify-content: flex-end; + margin: 0 0 8px; + width: 100%; +} + +/* Template label text */ + +.template-label-text { + display: flex !important; + align-items: center !important; + justify-content: center !important; + padding: 6px 14px; + font-size: 18px; + font-weight: 700 !important; + line-height: 18px; + color: #6c757d !important; + background-color: transparent; + border: 1px solid #6c757d !important; + border-radius: 3px; + cursor: pointer !important; + transition: all 0.15s ease-in-out; +} + +.template-label-text:hover { + background-color: #6c757d; + color: #fff !important; +} + + + +.template-label-text.no-permission { + cursor: default; + background: #f9f9f9; +} + +/* TinyMCE-style toolbar group for templates button */ +.templates-toolbar-group { + display: flex; + align-items: center; + gap: 0; + padding: 0; + margin: 0; +} + +/* TinyMCE-style menu button */ +.tinymce-menu-button { + display: inline-flex; + align-items: center; + padding: 0; + background: #f0f0f0; + border: 1px solid #ccc; + border-radius: 3px; + cursor: pointer; + font-size: 14px; + white-space: nowrap; + user-select: none; + position: relative; + height: 32px; + min-width: 60px; + transition: background-color 0.1s ease-in-out; +} + +.tinymce-menu-button:hover { + background: #e0e0e0; +} + +.tinymce-menu-button:active { + background: #d0d0d0; +} + +.tinymce-menu-button.tox-tbtn--select { + justify-content: space-between; + padding: 0 8px; + gap: 4px; +} + +.tox-tbtn__select-label { + display: inline-block; + font-weight: 500; + color: #333; + font-size: 13px; +} + +.tox-tbtn__select-chevron { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.tox-tbtn__select-chevron svg { + width: 10px; + height: 10px; + display: block; +} + +/* Dropdown menu styling */ +.templates-dropdown-menu { + position: fixed !important; + background: #fff; + border: 1px solid #ccc; + border-radius: 3px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + z-index: 9999 !important; + min-width: 250px; + max-height: 300px; + overflow-y: auto; + margin-top: 2px; +} + +/* Custom dropdown menu styling (not Bootstrap) */ +.custom-dropdown-menu { + list-style: none; + padding: 0 !important; + margin: 0 !important; + display: block !important; +} + +.custom-dropdown-item { + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #eee; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.15s ease-in-out; + display: block !important; +} + +.custom-dropdown-item:hover { + background: #f5f5f5; +} + +.custom-dropdown-item:last-child { + border-bottom: none !important; +} + +/* Position Templates button in top right of toolbar */ +.tox:has(#EmailBody) .templates-button-container { + position: absolute !important; + top: 5px !important; + right: 10px !important; + z-index: 10 !important; +} + +.tox *:not(svg):not(rect) { + background-color: initial; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + font-size: 14px !important; + font-weight: 400 !important; + font-style: normal; + color: rgb(34, 47, 62) !important; +} + +/* Style the Templates select element */ +.templates-select { + background: #f7f7f7 !important; + padding: 4px 8px !important; + border: 1px solid #ccc !important; + border-radius: 3px !important; + cursor: pointer !important; + font-size: 13px !important; + white-space: nowrap !important; + user-select: none !important; + height: 37px !important; +} + + + + +.templates-select:active, +.templates-select:focus { + outline: none !important; +} + diff --git a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js index ba75a8b601..34dc4acb99 100644 --- a/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js +++ b/applications/Unity.GrantManager/src/Unity.GrantManager.Web/Views/Shared/Components/EmailsWidget/Default.js @@ -1,14 +1,30 @@ -$(function () { - const emptyGuid = '00000000-0000-0000-0000-000000000000'; +$(document).ready(function () { + const BC_PERMANENT_DST_ZONE = 'UTC-7'; + // Close dropdown menus when clicking outside + $(document).on('click', function (e) { + if (!$(e.target).closest('.tinymce-menu-button, .custom-dropdown-menu').length) { + $('.custom-dropdown-menu').remove(); + } + }); + const UIElements = { applicationId: $('#DetailsViewApplicationId')[0].value, - btnSend: $('#btn-send'), - btnSave: $('#btn-save'), - btnDiscard: $('#btn-send-discard'), + btnSend: $('#btn-send-top'), + btnSendDropdown: $('#btn-send-dropdown'), + btnSave: $('#btn-save-top'), + btnDiscard: $('#btn-send-discard-top'), + btnSendClose: $('#btn-send-close-top'), btnConfirmSend: $('#btn-confirm-send'), btnCancelEmail: $('#btn-cancel-email'), btnNewEmail: $('#btn-new-email'), - btnSendClose: $('#btn-send-close'), + btnShowBCC: $('#btn-show-bcc'), + btnEmailTypeMail: $('#email-type-mail'), + btnEmailTypeTemplate: $('#email-type-template'), + templateModalBackdrop: $('#template-modal-backdrop'), + templateModal: $('#template-selection-modal'), + templateSelectionDropdown: $('#TemplateSelectionDropdown'), + btnTemplateCancel: $('#btn-template-cancel'), + btnTemplateModalClose: $('#btn-template-modal-close'), emailForm: $('#EmailForm'), inputEmailId: $('#EmailId'), inputEmailTo: $($('#EmailTo')[0]), @@ -26,7 +42,26 @@ inputOriginalEmailBody: $($('#OriginalDraftEmailBody')[0]), emailSpinner: $('#spinner-modal'), confirmationModal: $('#confirmation-modal'), - alertEmailReadonly: $('#email-alert-readonly') + alertEmailReadonly: $('#email-alert-readonly'), + inputSendOnDateTime: $('#SendOnDateTime'), + delayDateTimeValidation: $('#delay-datetime-validation'), + sendOnDisplay: $('#send-on-display'), + btnClearSchedule: $('#btn-clear-schedule'), + scheduleModalBackdrop: $('#schedule-modal-backdrop'), + scheduleModal: $('#schedule-send-modal'), + scheduleModalValidation: $('#schedule-modal-validation'), + scheduleCalendarGrid: $('#schedule-calendar-grid'), + calendarMonthYear: $('#calendar-month-year'), + btnCalendarPrev: $('#btn-calendar-prev'), + btnCalendarNext: $('#btn-calendar-next'), + scheduleDateInput: $('#schedule-date-input'), + scheduleDateValidation: $('#schedule-date-validation'), + scheduleTimeSelect: $('#schedule-time-select'), + btnScheduleCancel: $('#btn-schedule-cancel'), + btnScheduleConfirm: $('#btn-schedule-confirm'), + btnScheduleModalClose: $('#btn-schedule-modal-close'), + btnOpenScheduleModal: $('#btn-open-schedule-modal'), + bccInputRow: $('#bcc-input-row') }; let defaultValues = { @@ -40,15 +75,66 @@ let editorInstance; let isNewEmailDraft = false; let newDraftId = null; + let selectedEmailData = null; // Store original email data when selected from table let emailAttachmentsTable = null; + let activeTemplateId = null; // Track if a template has been applied to the current draft + let originalTemplateState = { name: '', id: '' }; // Baseline template for discard restore + let activeTemplateAttachmentCount = 0; // Track how many attachments were copied from the active template + let isApplicationEmailContext = true; // Flag to track if we're in application email or template preview context + let cachedTemplates = null; // Cache templates globally + let scheduleState = { + currentMonth: new Date().getMonth(), + currentYear: new Date().getFullYear(), + selectedDate: null, + selectedTime: null + }; + + function bindUIEvents() { - UIElements.btnNewEmail.on('click', handleNewEmail); + // Remove any existing event handlers to prevent duplicates + UIElements.btnNewEmail.off('click'); + UIElements.btnSend.off('click'); + UIElements.btnSave.off('click'); + UIElements.btnDiscard.off('click'); + UIElements.btnSendClose.off('click'); + UIElements.btnConfirmSend.off('click'); + UIElements.btnCancelEmail.off('click'); + UIElements.btnTemplateCancel.off('click'); + UIElements.btnTemplateModalClose.off('click'); + $('.btn-send-menu').off('click'); + $('.btn-schedule-send-menu').off('click'); + + // Bind button handlers + UIElements.btnNewEmail.on('click', function (e) { + e.preventDefault(); + handleNewEmail(false); + }); UIElements.btnSend.on('click', handleSendEmail); UIElements.btnSave.on('click', handleSaveEmail); UIElements.btnDiscard.on('click', handleDiscardEmail); + UIElements.btnSendClose.on('click', handleCloseEmail); + + // Send dropdown menu items + $('.btn-send-menu').on('click', function (e) { + e.preventDefault(); + handleSendEmail(e); + }); + + $('.btn-schedule-send-menu').on('click', function (e) { + e.preventDefault(); + // Reset schedule state and open modal + scheduleState.currentMonth = new Date().getMonth(); + scheduleState.currentYear = new Date().getFullYear(); + scheduleState.selectedDate = null; + scheduleState.selectedTime = null; + openScheduleModal(scheduleState); + }); + UIElements.btnConfirmSend.on('click', handleConfirmSendEmail); UIElements.btnCancelEmail.on('click', handleCancelEmailSend); - UIElements.btnSendClose.on('click', handleCloseEmail); + UIElements.btnTemplateCancel.on('click', closeTemplateSelectionModal); + UIElements.btnTemplateModalClose.on('click', closeTemplateSelectionModal); + UIElements.templateSelectionDropdown.on('change', handleTemplateSelection); UIElements.inputEmailSubject.on('change', handleKeyUpTrim); UIElements.inputEmailFrom.on('change', handleKeyUpTrim); UIElements.inputEmailCC.on('change', handleKeyUpTrim); @@ -58,113 +144,707 @@ UIElements.inputEmailCC.on('change', validateEmailCC); UIElements.inputEmailBCC.on('change', validateEmailBCC); + // Add real-time validation on input event (as user types) - show errors without toast + UIElements.inputEmailTo.on('input', function () { + validateEmailFieldWithOptions(UIElements.inputEmailToField, false, false, false); // showToast=false, onlyShowErrorsIfHasContent=false + }); + UIElements.inputEmailCC.on('input', function () { + validateEmailFieldWithOptions(UIElements.inputEmailCC[0], false, false, false); + }); + UIElements.inputEmailBCC.on('input', function () { + validateEmailFieldWithOptions(UIElements.inputEmailBCC[0], false, false, false); + }); + + // Add blur event handler to validate and show errors when leaving field + UIElements.inputEmailTo.on('blur', function () { + validateEmailFieldWithOptions(UIElements.inputEmailToField, false, false, false); // showToast=false + }); + UIElements.inputEmailCC.on('blur', function () { + validateEmailFieldWithOptions(UIElements.inputEmailCC[0], false, false, false); + }); + UIElements.inputEmailBCC.on('blur', function () { + validateEmailFieldWithOptions(UIElements.inputEmailBCC[0], false, false, false); + }); + UIElements.inputEmailTo.on('input', handleDraftChange); + UIElements.inputEmailTo.on('change', handleDraftChange); UIElements.inputEmailCC.on('input', handleDraftChange); + UIElements.inputEmailCC.on('change', handleDraftChange); UIElements.inputEmailBCC.on('input', handleDraftChange); + UIElements.inputEmailBCC.on('change', handleDraftChange); UIElements.inputEmailFrom.on('input', handleDraftChange); + UIElements.inputEmailFrom.on('change', handleDraftChange); UIElements.inputEmailSubject.on('input', handleDraftChange); + UIElements.inputEmailSubject.on('change', handleDraftChange); UIElements.inputEmailBody.on('input', handleDraftChange); + UIElements.inputEmailBody.on('change', handleDraftChange); + + // BCC button and input handlers + UIElements.btnShowBCC.on('click', handleShowBCC); + UIElements.inputEmailBCC.on('input', toggleBCCVisibility); + UIElements.inputEmailBCC.on('change', toggleBCCVisibility); + + bindDelayModeEvents(); $('.details-scrollable').on('scroll.emailWidget', function () { $('.tox-toolbar__overflow').hide(); }); + + // Initialize BCC visibility on load + toggleBCCVisibility(); } init(); + function initializeValidator() { + UIElements.emailForm.validate({ + errorClass: 'field-validation-error', + validClass: 'field-validation-valid', + highlight: function (element, errorClass, validClass) { + $(element).addClass('input-validation-error').removeClass(validClass); + }, + unhighlight: function (element, errorClass, validClass) { + $(element).removeClass('input-validation-error').addClass(validClass); + }, + errorPlacement: function (error, element) { + // Create or update error span after the element + let errorSpan = element.siblings('.field-validation-error'); + if (errorSpan.length === 0) { + errorSpan = $('').insertAfter(element); + } + errorSpan.text(error.text()); + } + }); + } + function init() { bindUIEvents(); + initializeValidator(); defaultValues.emailTo = UIElements.inputOriginalEmailTo.val(); defaultValues.emailFrom = UIElements.inputOriginalEmailFrom.val(); defaultValues.emailCC = UIElements.inputOriginalEmailCC.val() || ''; defaultValues.emailBCC = UIElements.inputOriginalEmailBCC.val() || ''; - if (window.toastr) { toastr.options.positionClass = 'toast-top-center'; } + if (globalThis.toastr) { toastr.options.positionClass = 'toast-top-center'; } + preloadTemplates(); // Pre-fetch templates on page load initTemplateDetails(); $('#templateTextContainer').hide(); + $('#scheduled-delay-section').hide(); UIElements.btnSave.hide(); UIElements.btnSend.hide(); + UIElements.btnSendDropdown.hide(); UIElements.btnDiscard.hide(); UIElements.btnSendClose.hide(); } async function initTemplateDetails() { - applicationDetails = await loadApplicationDetails(); - mappingConfig = await getTemplateVariables(); + applicationDetails = await loadApplicationDetails(); + mappingConfig = await getTemplateVariables(); } function disableEmail() { - UIElements.btnSend.attr('disabled', true); - UIElements.btnSave.attr('disabled', true); - UIElements.btnDiscard.attr('disabled', true); - UIElements.inputEmailTo.attr('disabled', true); - UIElements.inputEmailCC.attr('disabled', true); - UIElements.inputEmailBCC.attr('disabled', true); - UIElements.inputEmailFrom.attr('disabled', true); - UIElements.inputEmailSubject.attr('disabled', true); - UIElements.inputEmailBody.attr('disabled', true); + UIElements.btnSend.prop('disabled', true); + UIElements.btnSendDropdown.prop('disabled', true); + UIElements.btnSave.prop('disabled', true); + UIElements.btnDiscard.prop('disabled', true); + UIElements.inputEmailTo.prop('disabled', true); + UIElements.inputEmailCC.prop('disabled', true); + UIElements.inputEmailBCC.prop('disabled', true); + UIElements.inputEmailFrom.prop('disabled', true); + UIElements.inputEmailSubject.prop('disabled', true); + UIElements.inputEmailBody.prop('disabled', true); + + // Make TinyMCE read-only if it exists + const editor = tinymce.get('EmailBody'); + editor?.mode.set('readonly'); } function enableEmail() { - UIElements.btnSend.attr('disabled', false); - UIElements.btnSave.attr('disabled', false); - UIElements.btnDiscard.attr('disabled', false); - UIElements.inputEmailTo.attr('disabled', false); - UIElements.inputEmailCC.attr('disabled', false); - UIElements.inputEmailBCC.attr('disabled', false); - UIElements.inputEmailFrom.attr('disabled', false); - UIElements.inputEmailSubject.attr('disabled', false); - UIElements.inputEmailBody.attr('disabled', false); + UIElements.btnSend.prop('disabled', false); + UIElements.btnSendDropdown.prop('disabled', false); + UIElements.btnSave.prop('disabled', false); + UIElements.btnDiscard.prop('disabled', false); + UIElements.inputEmailTo.prop('disabled', false); + UIElements.inputEmailCC.prop('disabled', false); + UIElements.inputEmailBCC.prop('disabled', false); + UIElements.inputEmailFrom.prop('disabled', false); + UIElements.inputEmailSubject.prop('disabled', false); + UIElements.inputEmailBody.prop('disabled', false); + + // Make TinyMCE editable if it exists + const editor = tinymce.get('EmailBody'); + editor?.mode.set('design'); + } + + function validateScheduleDate() { + const dateStr = UIElements.scheduleDateInput.val(); + // Clear any previous validation messages and error toasts + UIElements.scheduleDateValidation.removeClass('text-success').removeClass('text-danger').text('').hide(); + + if (dateStr?.length === 10) { + const [month, day, year] = dateStr.split('/').map(Number); + if (isValidDate(month, day, year)) { + scheduleState.selectedDate = new Date(year, month - 1, day); + scheduleState.currentMonth = month - 1; + scheduleState.currentYear = year; + renderCalendarGrid(scheduleState); + UIElements.scheduleDateValidation.text('✓ Valid date').removeClass('text-danger').addClass('text-success').show(); + } else { + const errorMsg = '✗ Invalid date'; + UIElements.scheduleDateValidation.text(errorMsg).removeClass('text-success').addClass('text-danger').show(); + showValidationErrorToast([errorMsg]); + } + } else if (dateStr) { + const errorMsg = '✗ Invalid date format (use MM/DD/YYYY)'; + UIElements.scheduleDateValidation.text(errorMsg).removeClass('text-success').addClass('text-danger').show(); + showValidationErrorToast([errorMsg]); + } else { + UIElements.scheduleDateValidation.hide(); + } + } + + function bindDelayModeEvents() { + // Use global scheduleState + scheduleState.currentMonth = new Date().getMonth(); + scheduleState.currentYear = new Date().getFullYear(); + scheduleState.selectedDate = null; + scheduleState.selectedTime = null; + + // Initialize time dropdown with 30-minute intervals + initializeTimeDropdown(); + + // Open modal + UIElements.btnOpenScheduleModal.on('click', function () { + openScheduleModal(scheduleState); + }); + + // Close modal + UIElements.btnScheduleModalClose.on('click', function () { + closeScheduleModal(); + }); + UIElements.btnScheduleCancel.on('click', function () { + closeScheduleModal(); + }); + UIElements.scheduleModalBackdrop.on('click', function () { + closeScheduleModal(); + }); + + // Calendar navigation + UIElements.btnCalendarPrev.on('click', function () { + scheduleState.currentMonth--; + if (scheduleState.currentMonth < 0) { + scheduleState.currentMonth = 11; + scheduleState.currentYear--; + } + renderCalendarGrid(scheduleState); + }); + + UIElements.btnCalendarNext.on('click', function () { + scheduleState.currentMonth++; + if (scheduleState.currentMonth > 11) { + scheduleState.currentMonth = 0; + scheduleState.currentYear++; + } + renderCalendarGrid(scheduleState); + }); + + // Date input two-way binding with input masking for MM/DD/YYYY + UIElements.scheduleDateInput.on('input', function () { + let val = UIElements.scheduleDateInput.val().replaceAll(/\D/, ''); + if (val.length > 8) val = val.substring(0, 8); + if (val.length >= 2) { + val = val.substring(0, 2) + '/' + val.substring(2); + } + if (val.length >= 5) { + val = val.substring(0, 5) + '/' + val.substring(5); + } + UIElements.scheduleDateInput.val(val); + + // Validate immediately when a complete date is entered (MM/DD/YYYY = 10 chars) + if (val.length === 10) { + validateScheduleDate(); + } else if (val.length < 10 && val.length > 0) { + // Clear validation messages while user is still typing an incomplete date + UIElements.scheduleDateValidation.hide(); + } + }); + + UIElements.scheduleDateInput.on('blur', function () { + validateScheduleDate(); + }); + + // Time dropdown + UIElements.scheduleTimeSelect.on('change', function () { + scheduleState.selectedTime = UIElements.scheduleTimeSelect.val(); + UIElements.scheduleModalValidation.hide(); + }); + + // Confirm button + UIElements.btnScheduleConfirm.on('click', function () { + confirmScheduleDateTime(scheduleState); + }); + + // Clear button + UIElements.btnClearSchedule.on('click', function () { + clearScheduleValue(); + }); + + + } + + function initializeTimeDropdown() { + const times = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 30) { + const ampm = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 === 0 ? 12 : hour % 12; + const timeStr = `${displayHour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')} ${ampm}`; + const timeValue = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; + times.push({ label: timeStr, value: timeValue }); + } + } + + UIElements.scheduleTimeSelect.empty().append(''); + times.forEach(t => { + UIElements.scheduleTimeSelect.append(``); + }); + } + + function getMaxScheduleDate() { + const max = new Date(); + max.setFullYear(max.getFullYear() + 50); + return max; + } + + function renderCalendarGrid(state) { + // Update header + const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + UIElements.calendarMonthYear.text(`${monthNames[state.currentMonth]} ${state.currentYear}`); + + // Clear grid + UIElements.scheduleCalendarGrid.empty(); + + // Day headers + const dayHeaders = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + dayHeaders.forEach(day => { + UIElements.scheduleCalendarGrid.append(`
    ${day}
    `); + }); + + // Get first day of month and number of days + const firstDay = new Date(state.currentYear, state.currentMonth, 1).getDay(); + const daysInMonth = new Date(state.currentYear, state.currentMonth + 1, 0).getDate(); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const maxDate = getMaxScheduleDate(); + maxDate.setHours(0, 0, 0, 0); + + // Add empty cells for days before month starts + for (let i = 0; i < firstDay; i++) { + UIElements.scheduleCalendarGrid.append('
    '); + } + + // Add day cells + for (let day = 1; day <= daysInMonth; day++) { + const cellDate = new Date(state.currentYear, state.currentMonth, day); + cellDate.setHours(0, 0, 0, 0); + const isPast = cellDate < today; + const isFuture = cellDate > maxDate; + const isToday = cellDate.getTime() === today.getTime(); + const isSelected = state.selectedDate?.getTime() === cellDate.getTime(); + + let classes = 'calendar-day'; + if (isPast) classes += ' past'; + if (isFuture) classes += ' past'; + if (isToday) classes += ' today'; + if (isSelected) classes += ' selected'; + + const $dayCell = $(`
    ${day}
    `); + + if (!isPast && !isFuture) { + $dayCell.on('click', function () { + // Clear error immediately when user clicks a date + UIElements.scheduleDateValidation.removeClass('text-success').removeClass('text-danger').text('').hide(); + + state.selectedDate = new Date(state.currentYear, state.currentMonth, day); + const month = (state.currentMonth + 1).toString().padStart(2, '0'); + const dayStr = day.toString().padStart(2, '0'); + UIElements.scheduleDateInput.val(`${month}/${dayStr}/${state.currentYear}`); + validateScheduleDate(); + renderCalendarGrid(state); + }); + } + + UIElements.scheduleCalendarGrid.append($dayCell); + } + } + + function openScheduleModal(state) { + // Show modal and backdrop first + UIElements.scheduleModalBackdrop.show(); + UIElements.scheduleModal.addClass('active'); + + // Bind Escape key only when modal is open + $(document).off('keydown.scheduleModal').on('keydown.scheduleModal', function (e) { + if (e.key === 'Escape') { + closeScheduleModal(); + } + }); + + // Pre-populate with existing value if set, otherwise default to today + const existing = UIElements.inputSendOnDateTime.val(); + if (existing) { + const existingDateTime = luxon.DateTime.fromISO(existing, { zone: 'utc' }).setZone(BC_PERMANENT_DST_ZONE); + const bcPstDate = existingDateTime.isValid ? existingDateTime.toJSDate() : new Date(existing); + state.selectedDate = bcPstDate; + state.currentMonth = bcPstDate.getMonth(); + state.currentYear = bcPstDate.getFullYear(); + const month = (bcPstDate.getMonth() + 1).toString().padStart(2, '0'); + const day = bcPstDate.getDate().toString().padStart(2, '0'); + UIElements.scheduleDateInput.val(`${month}/${day}/${bcPstDate.getFullYear()}`); + const hour = bcPstDate.getHours().toString().padStart(2, '0'); + const minute = bcPstDate.getMinutes().toString().padStart(2, '0'); + UIElements.scheduleTimeSelect.val(`${hour}:${minute}`); + state.selectedTime = `${hour}:${minute}`; + } else { + // Default to today + 1 day at 9:00 AM + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); + + state.selectedDate = tomorrow; + state.currentMonth = tomorrow.getMonth(); + state.currentYear = tomorrow.getFullYear(); + + const month = (tomorrow.getMonth() + 1).toString().padStart(2, '0'); + const day = tomorrow.getDate().toString().padStart(2, '0'); + UIElements.scheduleDateInput.val(`${month}/${day}/${tomorrow.getFullYear()}`); + UIElements.scheduleTimeSelect.val('09:00'); + state.selectedTime = '09:00'; + } + + // Use setTimeout to ensure modal is visible before rendering + setTimeout(function () { + renderCalendarGrid(state); + UIElements.scheduleModalValidation.hide(); + UIElements.scheduleDateValidation.hide(); + UIElements.scheduleDateInput.focus(); + }, 100); } - function handleKeyUpTrim(e) { - let trimmedString = e.currentTarget.value.trim(); - e.currentTarget.value = trimmedString; + function closeScheduleModal() { + UIElements.scheduleModal.removeClass('active'); + UIElements.scheduleModalBackdrop.hide(); + $(document).off('keydown.scheduleModal'); + // Clear modal validation message + UIElements.scheduleModalValidation.hide(); + } + + function confirmScheduleDateTime(state) { + if (!state.selectedDate || !state.selectedTime) { + const errorMsg = 'Please select both a date and time.'; + UIElements.scheduleModalValidation.text(errorMsg).show(); + showValidationErrorToast([errorMsg]); + return; // Keep modal open on validation error + } + + // Build selected date/time in BC Pacific timezone. + const [hours, minutes] = state.selectedTime.split(':').map(Number); + const bcSelection = luxon.DateTime.fromObject( + { + year: state.selectedDate.getFullYear(), + month: state.selectedDate.getMonth() + 1, + day: state.selectedDate.getDate(), + hour: hours, + minute: minutes, + second: 0, + millisecond: 0 + }, + { zone: BC_PERMANENT_DST_ZONE } + ); + + if (!bcSelection.isValid) { + const errorMsg = 'Invalid date/time selection.'; + UIElements.scheduleModalValidation.text(errorMsg).show(); + showValidationErrorToast([errorMsg]); + return; + } + + // Validate future date/time + const now = luxon.DateTime.now().setZone(BC_PERMANENT_DST_ZONE); + if (bcSelection <= now) { + const errorMsg = 'Please select a future date and time.'; + UIElements.scheduleModalValidation.text(errorMsg).show(); + showValidationErrorToast([errorMsg]); + return; // Keep modal open on validation error + } + + // Validate max date (50 years from today) + const maxDate = getMaxScheduleDate(); + if (bcSelection.toJSDate() > maxDate) { + const errorMsg = 'Please select a date within 50 years from today.'; + UIElements.scheduleModalValidation.text(errorMsg).show(); + showValidationErrorToast([errorMsg]); + return; // Keep modal open on validation error + } + + // Convert BC Pacific wall time to UTC for storage/submission. + const utcIso = bcSelection.toUTC().toISO({ suppressMilliseconds: true }); + + // Commit to hidden field + UIElements.inputSendOnDateTime.val(utcIso); + + // Display in BC Pacific timezone. + const formattedDate = bcSelection.toFormat('yyyy-MM-dd'); + const formattedTime = bcSelection.toFormat('h:mm a'); + UIElements.sendOnDisplay.text(`${formattedDate}, ${formattedTime}`); + UIElements.btnClearSchedule.show(); + $('#scheduled-label-container').addClass('show'); + $('#scheduled-delay-section').show(); + UIElements.delayDateTimeValidation.hide(); + closeScheduleModal(); + } + + + + function updateScheduledDateDisplay() { + const dateTimeValue = UIElements.inputSendOnDateTime.val(); + if (dateTimeValue) { + // Stored value is UTC; render in BC Pacific timezone. + const dt = luxon.DateTime.fromISO(dateTimeValue, { zone: 'utc' }).setZone(BC_PERMANENT_DST_ZONE); + if (dt.isValid) { + UIElements.sendOnDisplay.text(`${dt.toFormat('yyyy-MM-dd')}, ${dt.toFormat('h:mm a')}`); + } else { + UIElements.sendOnDisplay.text(''); + } + UIElements.btnClearSchedule.show(); + $('#scheduled-label-container').addClass('show'); + $('#scheduled-delay-section').show(); + } else { + UIElements.sendOnDisplay.text(''); + UIElements.btnClearSchedule.hide(); + $('#scheduled-label-container').removeClass('show'); + $('#scheduled-delay-section').hide(); + } + } + + function clearScheduleValue() { + UIElements.inputSendOnDateTime.val(''); + UIElements.sendOnDisplay.text(''); + UIElements.btnClearSchedule.hide(); + $('#scheduled-label-container').removeClass('show'); + $('#scheduled-delay-section').hide(); + UIElements.delayDateTimeValidation.hide(); + UIElements.scheduleDateInput.val(''); + UIElements.scheduleTimeSelect.val(''); } function closeEmailFormUI() { + // Close any open dropdowns + const dropdownIds = ['btn-send-dropdown']; + dropdownIds.forEach(id => { + bootstrap.Dropdown.getInstance(document.getElementById(id))?.hide(); + }); + + // Clear all validation errors + UIElements.emailForm.find('.input-validation-error').removeClass('input-validation-error').addClass('field-validation-valid'); + UIElements.emailForm.find('.field-validation-error').html('').removeClass('field-validation-error').addClass('field-validation-valid'); + + // Reset jQuery validator state + const validator = UIElements.emailForm.validate(); + validator?.resetForm(); + + + // Clear toastr notifications + if (globalThis.toastr) { + toastr.clear(); + } + $('#modal-content, #modal-background').removeClass('active'); UIElements.emailForm.removeClass('active'); + $('#EmailTemplateName').val(''); UIElements.btnNewEmail.removeClass('hide'); UIElements.alertEmailReadonly.removeClass('hide'); UIElements.emailForm.trigger("reset"); + clearScheduleValue(); $('#email-attachments-section').hide(); enableEmail(); UIElements.btnSave.hide(); UIElements.btnSend.hide(); + UIElements.btnSendDropdown.hide(); UIElements.btnDiscard.hide(); UIElements.btnSendClose.hide(); + UIElements.bccInputRow.removeClass('show'); + UIElements.btnShowBCC.removeClass('hide'); + + // Clear the stored selected email data when form is closed + selectedEmailData = null; + + // Reset draft viewing flag + isViewingDraft = false; } 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; + isNewEmailDraft = false; newDraftId = null; } + activeTemplateId = null; + originalTemplateState = { name: '', id: '' }; + activeTemplateAttachmentCount = 0; closeEmailFormUI(); } function handleDiscardEmail() { - UIElements.inputEmailTo.val(UIElements.inputOriginalEmailTo.val()); - UIElements.inputEmailCC.val(UIElements.inputOriginalEmailCC.val()); - UIElements.inputEmailBCC.val(UIElements.inputOriginalEmailBCC.val()); - UIElements.inputEmailFrom.val(UIElements.inputOriginalEmailFrom.val()); - UIElements.inputEmailSubject.val(UIElements.inputOriginalEmailSubject.val()); - UIElements.inputEmailBody.val(UIElements.inputOriginalEmailBody.val()); + // If it's a new email draft, delete it and reset the form + if (isNewEmailDraft && newDraftId) { + $.ajax({ + url: `/api/app/email-notification/${newDraftId}/email`, + type: 'DELETE' + }) + .done(() => { + isNewEmailDraft = false; + newDraftId = null; + // Reset all fields to empty + UIElements.inputEmailTo.val(''); + UIElements.inputEmailCC.val(''); + UIElements.inputEmailBCC.val(''); + UIElements.inputEmailFrom.val(''); + UIElements.inputEmailSubject.val(''); + UIElements.inputEmailBody.val(''); + // Reset TinyMCE editor + if (tinymce.get("EmailBody")) { + tinymce.get("EmailBody").setContent(''); + } + // Reset original values + UIElements.inputOriginalEmailTo.val(''); + UIElements.inputOriginalEmailCC.val(''); + UIElements.inputOriginalEmailBCC.val(''); + UIElements.inputOriginalEmailFrom.val(''); + UIElements.inputOriginalEmailSubject.val(''); + UIElements.inputOriginalEmailBody.val(''); + // Clear template + $('#EmailTemplate').val('').trigger('change'); + $('#EmailTemplateName').val(''); + activeTemplateId = null; + originalTemplateState = { name: '', id: '' }; + updateSelectedTemplateLabel('', ''); + // Reset scheduled send + clearScheduleValue(); + // Reset validation errors + resetValidationErrors(); + // Reset draft change state + handleDraftChange(); + // Reset BCC visibility + toggleBCCVisibility(); + // Show success toast + if (globalThis.toastr) { + toastr.success('Changes discarded', 'Email Reset'); + } + }) + .fail(e => { + console.warn('Failed to delete draft on discard:', e); + // Still reset the form even if delete fails + isNewEmailDraft = false; + newDraftId = null; + // Reset all fields to empty + UIElements.inputEmailTo.val(''); + UIElements.inputEmailCC.val(''); + UIElements.inputEmailBCC.val(''); + UIElements.inputEmailFrom.val(''); + UIElements.inputEmailSubject.val(''); + UIElements.inputEmailBody.val(''); + // Reset TinyMCE editor + if (tinymce.get("EmailBody")) { + tinymce.get("EmailBody").setContent(''); + } + // Reset original values + UIElements.inputOriginalEmailTo.val(''); + UIElements.inputOriginalEmailCC.val(''); + UIElements.inputOriginalEmailBCC.val(''); + UIElements.inputOriginalEmailFrom.val(''); + UIElements.inputOriginalEmailSubject.val(''); + UIElements.inputOriginalEmailBody.val(''); + // Clear template + $('#EmailTemplate').val('').trigger('change'); + $('#EmailTemplateName').val(''); + activeTemplateId = null; + originalTemplateState = { name: '', id: '' }; + updateSelectedTemplateLabel('', ''); + // Reset scheduled send + clearScheduleValue(); + // Reset validation errors + resetValidationErrors(); + // Reset draft change state + handleDraftChange(); + // Reset BCC visibility + toggleBCCVisibility(); + }); + } else { + // Reset existing email to stored original data + if (selectedEmailData) { + const originalTemplateName = originalTemplateState.name; + const originalTemplateId = originalTemplateState.id; + + UIElements.inputEmailTo.val(selectedEmailData.toAddress); + UIElements.inputEmailCC.val(selectedEmailData.cc?.replaceAll(',', '; ') ?? ''); + UIElements.inputEmailBCC.val(selectedEmailData.bcc?.replaceAll(',', '; ') ?? ''); + UIElements.inputEmailFrom.val(selectedEmailData.fromAddress); + UIElements.inputEmailSubject.val(selectedEmailData.subject); + UIElements.inputEmailBody.val(refreshTodayDateSpans(selectedEmailData.body)); + $('#EmailTemplateName').val(originalTemplateName); + activeTemplateId = originalTemplateId || null; + updateSelectedTemplateLabel(originalTemplateName, originalTemplateId); + + // Reset TinyMCE editor + const resetBodyContent = selectedEmailData.body ? refreshTodayDateSpans(selectedEmailData.body) : ''; + tinymce.get("EmailBody")?.setContent(resetBodyContent || ''); + + // Reset scheduled send date/time to original state + if (selectedEmailData.sendOnDateTime) { + UIElements.inputSendOnDateTime.val(selectedEmailData.sendOnDateTime); + updateScheduledDateDisplay(); + } else { + clearScheduleValue(); + } + } else { + // Fallback to hidden inputs if selectedEmailData is not available + UIElements.inputEmailTo.val(UIElements.inputOriginalEmailTo.val()); + UIElements.inputEmailCC.val(UIElements.inputOriginalEmailCC.val()); + UIElements.inputEmailBCC.val(UIElements.inputOriginalEmailBCC.val()); + UIElements.inputEmailFrom.val(UIElements.inputOriginalEmailFrom.val()); + UIElements.inputEmailSubject.val(UIElements.inputOriginalEmailSubject.val()); + UIElements.inputEmailBody.val(UIElements.inputOriginalEmailBody.val()); + $('#EmailTemplateName').val(originalTemplateState.name || ''); + activeTemplateId = originalTemplateState.id || null; + updateSelectedTemplateLabel(originalTemplateState.name || '', originalTemplateState.id || ''); + + // Reset TinyMCE editor + const fallbackBodyContent = UIElements.inputOriginalEmailBody.val() || ''; + tinymce.get("EmailBody")?.setContent(fallbackBodyContent); + + // Reset scheduled send date/time to original state + clearScheduleValue(); + } + + // Clear validation errors + resetValidationErrors(); + + // Show success toast + if (globalThis.toastr) { + toastr.success('Changes discarded', 'Email Reset'); + } + + // Disable save and discard buttons since no changes are present + handleDraftChange(); + } } function handleCancelEmailSend() { $('#modal-content, #modal-background').removeClass('active'); } - function getToolbarOptions() { - return 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview'; - } - function getPlugins() { - return 'lists link image preview code'; - } function resetEmailBody() { const id = 'EmailBody'; @@ -190,16 +870,452 @@ }); } - async function handleNewEmail() { + function closeTemplateSelectionModal() { + UIElements.templateModalBackdrop.hide(); + UIElements.templateModal.hide(); + UIElements.templateSelectionDropdown.val(''); + } + + async function copyTemplateAttachments(templateId, emailLogId) { + try { + const response = await $.ajax({ + url: `/api/form-notifications/email-template/${templateId}/copy-attachments`, + type: 'POST', + data: JSON.stringify({ emailLogId: emailLogId }), + contentType: 'application/json' + }); + return response?.attachmentCount || 0; + } catch (e) { + console.warn('Failed to copy template attachments:', e); + return 0; + } + } + + async function deleteOriginAttachments(emailLogId) { + try { + const response = await $.ajax({ + url: `/api/form-notifications/email-log/${emailLogId}/origin-attachments`, + type: 'DELETE' + }); + return response?.attachmentCount || 0; + } catch (e) { + console.warn('Failed to delete origin attachments:', e); + return 0; + } + } + + function populateTemplatesSelectOptions($select, templates) { + const $placeholder = $select.find('option[value=""]').first(); + + // Always rebuild non-placeholder options to avoid duplicate entries. + $select.find('option').not($placeholder).remove(); + + const seenTemplateIds = new Set(); + templates.forEach((template) => { + const templateName = template.name || template.Name || 'Unnamed Template'; + const templateId = (template.id || template.Id || '').toString(); + if (!templateId || seenTemplateIds.has(templateId)) { + return; + } + + seenTemplateIds.add(templateId); + + const $option = $('