diff --git a/.github/workflows/cypress-e2e-runner.yml b/.github/workflows/cypress-e2e-runner.yml index 77d7f6fa06..63eab4dc70 100644 --- a/.github/workflows/cypress-e2e-runner.yml +++ b/.github/workflows/cypress-e2e-runner.yml @@ -5,16 +5,28 @@ name: Cypress E2E (runner) # 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 +# (from the unity-cypress-job Template in ce395f-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. # +# This deliberately targets ce395f-tools on the Gold cluster, not d18498-tools +# on Silver, even though the app environments (dev/test/uat/prod) still run on +# Silver during the cluster migration — the Job's own baseUrl (via the +# base_url input, or each cypress-config-*.json's default) is what it tests +# against, independent of which cluster runs the Job itself. Building the CI +# plumbing against the migration target now avoids redoing it later. +# # Test credentials are NOT GitHub secrets — the Job pulls them from the -# unity-cypress-config Secret in d18498-tools, synced from Vault +# unity-cypress-config Secret in ce395f-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. +# OpenShift auth reuses the pipeline ServiceAccount (ce395f-tools), which +# already holds ClusterRole/edit there — covers everything this job needs +# (templates, processedtemplates, jobs, pods/log, pods/exec), no additional +# RBAC required. Its token is CYPRESS_OPENSHIFT_TOKEN and its cluster is +# CYPRESS_OPENSHIFT_CLUSTER — both repo-level (not tied to a GitHub +# Environment, and deliberately separate from the shared OPENSHIFT_CLUSTER +# var docker-build-*.yml uses, since that one still points at Silver). on: workflow_call: @@ -32,27 +44,21 @@ on: 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 + TOOLS_NAMESPACE: ce395f-tools + JOB_TIMEOUT_SECONDS: 1200 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 }} + OC_CLUSTER: ${{ vars.CYPRESS_OPENSHIFT_CLUSTER }} + OC_AUTH_TOKEN: ${{ secrets.CYPRESS_OPENSHIFT_TOKEN }} GH_TOKEN: ${{ secrets.GH_API_TOKEN }} steps: @@ -68,9 +74,9 @@ jobs: - name: Launch Cypress Job id: launch run: | - # The unity-cypress-job Template lives in the tenant-gitops-d18498 repo + # The unity-cypress-job Template lives in the tenant-gitops-ce395f repo # and is synced into the cluster by ArgoCD — process it by name directly - # from d18498-tools rather than checking out that repo here. + # from ce395f-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 }} \ @@ -85,15 +91,30 @@ jobs: - name: Wait for Cypress Job to finish id: wait run: | + # Don't use `oc wait --for=condition=complete/failed` here: a freshly + # created Job has no .status.conditions entries yet, and + # --for=condition=X (without an explicit =true) is satisfied the + # instant the condition is merely absent rather than false — so it + # can report "condition met" seconds after creation, long before the + # Job has actually run. Poll the numeric succeeded/failed fields + # instead, which are only set once a pod genuinely finishes. 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 + DEADLINE=$(( $(date +%s) + JOB_TIMEOUT_SECONDS )) + SUCCEEDED=0 + FAILED=0 + while true; do + SUCCEEDED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.succeeded}') + FAILED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.failed}') + if [ "${SUCCEEDED:-0}" -ge 1 ] 2>/dev/null || [ "${FAILED:-0}" -ge 1 ] 2>/dev/null; then + break + fi + if [ "$(date +%s)" -ge "$DEADLINE" ]; then + echo "Timed out after ${JOB_TIMEOUT_SECONDS}s waiting for $JOB_REF" + break + fi + sleep 10 + done - SUCCEEDED=$(oc get "$JOB_REF" -n $TOOLS_NAMESPACE -o jsonpath='{.status.succeeded}') echo "succeeded=${SUCCEEDED:-0}" >> "$GITHUB_OUTPUT" - name: Show Cypress logs diff --git a/.github/workflows/cypress-prod.yml b/.github/workflows/cypress-prod.yml index 03ee93b499..a9e9fcde95 100644 --- a/.github/workflows/cypress-prod.yml +++ b/.github/workflows/cypress-prod.yml @@ -16,5 +16,4 @@ jobs: with: env_name: prod cypress_config_key: CYPRESS_CONFIG_PROD - gh_environment: main secrets: inherit diff --git a/.github/workflows/cypress-uat.yml b/.github/workflows/cypress-uat.yml index 4e3236b8f1..a33f814e2f 100644 --- a/.github/workflows/cypress-uat.yml +++ b/.github/workflows/cypress-uat.yml @@ -5,8 +5,6 @@ name: Cypress E2E — UAT # # 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. @@ -26,5 +24,4 @@ jobs: with: env_name: uat cypress_config_key: CYPRESS_CONFIG_UAT - gh_environment: main secrets: inherit diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml index be32d7f22a..d9cab254ff 100644 --- a/.github/workflows/docker-build-dev.yml +++ b/.github/workflows/docker-build-dev.yml @@ -24,7 +24,9 @@ env: GH_TOKEN: ${{secrets.GH_API_TOKEN}} OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} - OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + OC_TOOLS_TOKEN: ${{ secrets.TOOLS_OPENSHIFT_TOKEN }} + OC_TOOLS_PROJECT: ${{ vars.TOOLS_OPENSHIFT_NAMESPACE }} + OC_TARGET_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} @@ -47,7 +49,7 @@ jobs: run: | echo "Target: $TARGET_ENV" echo "BaseRef: $GITHUB_REF_NAME" - echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV OC_TOOLS_PROJECT=$OC_TOOLS_PROJECT OC_TARGET_PROJECT=$OC_TARGET_PROJECT" echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" echo "..." env | sort @@ -124,18 +126,15 @@ jobs: docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . working-directory: ./applications/Unity.GrantManager - - name: Connect to JFrog Artifactory non-interactive login using --password-stdin - run: | - echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry + continue-on-error: true run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - - name: Disconnect docker from JFrog Artifactory - run: | - docker logout + docker logout $JFROG_SERVICE - name: Install OpenShift CLI run: | curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz @@ -143,17 +142,30 @@ jobs: sudo mv oc /usr/local/bin - name: Verify OpenShift CLI installation run: oc version - - name: Connect to OpenShift API non-interactive login using current session token + - name: Push application images into ce395f-tools run: | - oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + oc login --token=$OC_TOOLS_TOKEN --server=$OC_CLUSTER oc registry login docker login -u unused -p $(oc whoami -t) $OC_REGISTRY - - name: Push application images to OpenShift container registry + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker logout $OC_REGISTRY + - name: Promote dbMigrator into ce395f-dev and run migrations run: | - docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - - name: Disconnect docker from OpenShift container registry + oc login --token=$OC_TARGET_TOKEN --server=$OC_CLUSTER + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + oc -n $OC_TARGET_PROJECT delete jobs $TARGET_ENV-unity-dbmigrator --ignore-not-found=true + oc -n $OC_TARGET_PROJECT process unity-grantmanager-dbmigrator-job \ + -p APPLICATION_GROUP=$TARGET_ENV-unity-grantmanager \ + -p DATABASE_SERVICE_NAME=$TARGET_ENV-unity-data-postgres \ + -p APPLICATION_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGEPULL_NAMESPACE=$OC_TARGET_PROJECT \ + -p IMAGESTREAM_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGESTREAM_TAG=latest | oc -n $OC_TARGET_PROJECT create -f - + oc -n $OC_TARGET_PROJECT wait jobs/$TARGET_ENV-unity-dbmigrator --for condition=complete --timeout=300s + - name: Promote grantmanager-web into ce395f-dev and wait run: | - docker logout + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + oc -n $OC_TARGET_PROJECT rollout status deployment/$TARGET_ENV-unity-grantmanager-web --timeout=180s diff --git a/.github/workflows/docker-build-dev2.yml b/.github/workflows/docker-build-dev2.yml new file mode 100644 index 0000000000..5a9cad5428 --- /dev/null +++ b/.github/workflows/docker-build-dev2.yml @@ -0,0 +1,124 @@ +name: Dev2 - Build & Push docker images + +on: + push: + branches: [ "dev2" ] + paths-ignore: + - '.github/**' + - '.gitignore' + - 'database/**' + - 'documentation/**' + - '**/docs/**' + - '**/README*' + - 'CODE_OF_CONDUCT.md' + - 'COMPLIANCE.yaml' + - 'CONTRIBUTING.md' + - 'LICENSE' + - 'README.md' + - 'SECURITY.md' + # Allow manual workflow triggering + workflow_dispatch: + +env: + TARGET_ENV: dev2 + GH_TOKEN: ${{secrets.GH_API_TOKEN}} + OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} + OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} + OC_TARGET_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} + JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} + JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} + JFROG_REPO_PATH: ${{ vars.ARTIFACTORY_REPO }} + JFROG_SERVICE: ${{ vars.ARTIFACTORY_SERVICE }} + +jobs: + Setup: + runs-on: ubuntu-latest + environment: dev2 + permissions: + contents: read + steps: + - name: Get variables + run: | + echo "Target: $TARGET_ENV" + echo "BaseRef: $GITHUB_REF_NAME" + echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" + echo "..." + env | sort + Branch: + needs: [Setup] + runs-on: ubuntu-latest + environment: dev2 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: '1' + - name: Get short commitId + id: get_commit + run: | + echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + outputs: + SHA_SHORT: ${{steps.get_commit.outputs.SHA_SHORT}} + Build: + needs: [Setup,Branch] + runs-on: ubuntu-latest + environment: dev2 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - name: Resolve build version (from dev) and revision (dev2's own commit) + run: | + echo "UGM_BUILD_VERSION=$(gh variable get UGM_BUILD_VERSION --env dev)" >> $GITHUB_ENV + echo "UGM_BUILD_REVISION=${{needs.Branch.outputs.SHA_SHORT}}" >> $GITHUB_ENV + - name: Build Docker images + run: | + rm -f ./docker-compose.override.yml + docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . + docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . + working-directory: ./applications/Unity.GrantManager + - name: Push application images to Artifactory container registry + continue-on-error: true + run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE + docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest + docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest + docker logout $JFROG_SERVICE + - 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: Verify OpenShift CLI installation + run: oc version + - name: Push application images into ce395f-dev (no ce395f-tools hop for dev2) + run: | + oc login --token=$OC_TARGET_TOKEN --server=$OC_CLUSTER + oc registry login + docker login -u unused -p $(oc whoami -t) $OC_REGISTRY + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + docker push $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + docker push $OC_REGISTRY/$OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + docker logout $OC_REGISTRY + - name: Run dbMigrator Job + run: | + oc -n $OC_TARGET_PROJECT delete jobs $TARGET_ENV-unity-dbmigrator --ignore-not-found=true + oc -n $OC_TARGET_PROJECT process unity-grantmanager-dbmigrator-job \ + -p APPLICATION_GROUP=$TARGET_ENV-unity-grantmanager \ + -p DATABASE_SERVICE_NAME=$TARGET_ENV-unity-data-postgres \ + -p APPLICATION_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGEPULL_NAMESPACE=$OC_TARGET_PROJECT \ + -p IMAGESTREAM_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGESTREAM_TAG=latest | oc -n $OC_TARGET_PROJECT create -f - + oc -n $OC_TARGET_PROJECT wait jobs/$TARGET_ENV-unity-dbmigrator --for condition=complete --timeout=300s + - name: Roll out grantmanager-web and wait + run: | + oc -n $OC_TARGET_PROJECT rollout restart deployment/$TARGET_ENV-unity-grantmanager-web + oc -n $OC_TARGET_PROJECT rollout status deployment/$TARGET_ENV-unity-grantmanager-web --timeout=180s diff --git a/.github/workflows/docker-build-main.yml b/.github/workflows/docker-build-main.yml index b146da454a..f967c20619 100644 --- a/.github/workflows/docker-build-main.yml +++ b/.github/workflows/docker-build-main.yml @@ -24,8 +24,8 @@ env: GH_TOKEN: ${{secrets.GH_API_TOKEN}} OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} - OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} - OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} + OC_TOOLS_TOKEN: ${{ secrets.TOOLS_OPENSHIFT_TOKEN }} + OC_TOOLS_PROJECT: ${{ vars.TOOLS_OPENSHIFT_NAMESPACE }} JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} JFROG_REPO_PATH: ${{ vars.ARTIFACTORY_REPO }} @@ -47,7 +47,7 @@ jobs: run: | echo "Target: $TARGET_ENV" echo "BaseRef: $GITHUB_REF_NAME" - echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV OC_TOOLS_PROJECT=$OC_TOOLS_PROJECT" echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" echo "..." env | sort @@ -181,18 +181,15 @@ jobs: docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . working-directory: ./applications/Unity.GrantManager - - name: Connect to JFrog Artifactory non-interactive login using --password-stdin - run: | - echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry + continue-on-error: true run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:stable docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:stable docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:stable docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:stable - - name: Disconnect docker from JFrog Artifactory - run: | - docker logout + docker logout $JFROG_SERVICE - name: Install OpenShift CLI run: | curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz @@ -200,17 +197,13 @@ jobs: sudo mv oc /usr/local/bin - name: Verify OpenShift CLI installation run: oc version - - name: Connect to OpenShift API non-interactive login using current session token + - name: Push application images into ce395f-tools run: | - oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + oc login --token=$OC_TOOLS_TOKEN --server=$OC_CLUSTER oc registry login docker login -u unused -p $(oc whoami -t) $OC_REGISTRY - - name: Push application images to OpenShift container registry - run: | - docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator:stable - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator:stable - docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web:stable - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web:stable - - name: Disconnect docker from OpenShift container registry - run: | - docker logout + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:stable + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:stable + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:stable + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:stable + docker logout $OC_REGISTRY diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml index 9728ee15d8..9ea19ba14e 100644 --- a/.github/workflows/docker-build-test.yml +++ b/.github/workflows/docker-build-test.yml @@ -24,7 +24,9 @@ env: GH_TOKEN: ${{secrets.GH_API_TOKEN}} OC_CLUSTER: ${{ vars.OPENSHIFT_CLUSTER }} OC_REGISTRY: ${{ vars.OPENSHIFT_REGISTRY }} - OC_AUTH_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} + OC_TOOLS_TOKEN: ${{ secrets.TOOLS_OPENSHIFT_TOKEN }} + OC_TOOLS_PROJECT: ${{ vars.TOOLS_OPENSHIFT_NAMESPACE }} + OC_TARGET_TOKEN: ${{ secrets.OPENSHIFT_TOKEN }} OC_TARGET_PROJECT: ${{ vars.OPENSHIFT_NAMESPACE }} JFROG_USERNAME: ${{ secrets.ARTIFACTORY_USERNAME }} JFROG_PASSWORD: ${{ secrets.ARTIFACTORY_PASSWORD }} @@ -47,7 +49,7 @@ jobs: run: | echo "Target: $TARGET_ENV" echo "BaseRef: $GITHUB_REF_NAME" - echo "Environment: $TARGET_ENV OC_TARGET_PROJECT=$OC_TARGET_PROJECT" + echo "Environment: $TARGET_ENV OC_TOOLS_PROJECT=$OC_TOOLS_PROJECT OC_TARGET_PROJECT=$OC_TARGET_PROJECT" echo "Environment: $TARGET_ENV JFROG_REPO_PATH=$JFROG_REPO_PATH" echo "..." env | sort @@ -160,18 +162,15 @@ jobs: docker build --build-arg UNITY_BUILD_VERSION=${{env.UGM_BUILD_VERSION}} --build-arg UNITY_BUILD_REVISION=${{env.UGM_BUILD_REVISION}} -t unity-grantmanager-web -f src/Unity.GrantManager.Web/Dockerfile . docker build -t unity-grantmanager-dbmigrator -f src/Unity.GrantManager.DbMigrator/Dockerfile . working-directory: ./applications/Unity.GrantManager - - name: Connect to JFrog Artifactory non-interactive login using --password-stdin - run: | - echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE - name: Push application images to Artifactory container registry + continue-on-error: true run: | + echo "$JFROG_PASSWORD" | docker login -u "$JFROG_USERNAME" --password-stdin $JFROG_SERVICE docker tag unity-grantmanager-dbmigrator $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-dbmigrator:latest docker tag unity-grantmanager-web $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest docker push $JFROG_SERVICE/$JFROG_REPO_PATH/unity-grantmanager-web:latest - - name: Disconnect docker from JFrog Artifactory - run: | - docker logout + docker logout $JFROG_SERVICE - name: Install OpenShift CLI run: | curl -LO https://mirror.openshift.com/pub/openshift-v4/clients/oc/latest/linux/oc.tar.gz @@ -179,17 +178,30 @@ jobs: sudo mv oc /usr/local/bin - name: Verify OpenShift CLI installation run: oc version - - name: Connect to OpenShift API non-interactive login using current session token + - name: Push application images into ce395f-tools run: | - oc login --token=$OC_AUTH_TOKEN --server=$OC_CLUSTER + oc login --token=$OC_TOOLS_TOKEN --server=$OC_CLUSTER oc registry login docker login -u unused -p $(oc whoami -t) $OC_REGISTRY - - name: Push application images to OpenShift container registry + docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest + docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker push $OC_REGISTRY/$OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest + docker logout $OC_REGISTRY + - name: Promote dbMigrator into ce395f-test and run migrations run: | - docker tag unity-grantmanager-dbmigrator $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-dbmigrator - docker tag unity-grantmanager-web $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - docker push $OC_REGISTRY/$OC_TARGET_PROJECT/unity-grantmanager-web - - name: Disconnect docker from OpenShift container registry + oc login --token=$OC_TARGET_TOKEN --server=$OC_CLUSTER + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-dbmigrator-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-dbmigrator:latest + oc -n $OC_TARGET_PROJECT delete jobs $TARGET_ENV-unity-dbmigrator --ignore-not-found=true + oc -n $OC_TARGET_PROJECT process unity-grantmanager-dbmigrator-job \ + -p APPLICATION_GROUP=$TARGET_ENV-unity-grantmanager \ + -p DATABASE_SERVICE_NAME=$TARGET_ENV-unity-data-postgres \ + -p APPLICATION_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGEPULL_NAMESPACE=$OC_TARGET_PROJECT \ + -p IMAGESTREAM_NAME=$TARGET_ENV-unity-dbmigrator \ + -p IMAGESTREAM_TAG=latest | oc -n $OC_TARGET_PROJECT create -f - + oc -n $OC_TARGET_PROJECT wait jobs/$TARGET_ENV-unity-dbmigrator --for condition=complete --timeout=300s + - name: Promote grantmanager-web into ce395f-test and wait run: | - docker logout + oc tag $OC_TOOLS_PROJECT/$TARGET_ENV-unity-grantmanager-build:latest $OC_TARGET_PROJECT/$TARGET_ENV-unity-grantmanager:latest + oc -n $OC_TARGET_PROJECT rollout status deployment/$TARGET_ENV-unity-grantmanager-web --timeout=180s diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts index 7bbdde7041..5ca15b0e3b 100644 --- a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts @@ -164,7 +164,10 @@ describe("Unity Login and check data from CHEFS", () => { // Enabling this makes cy.get / cy.contains pierce shadow DOM consistently across envs. before(() => { Cypress.config("includeShadowDom", true); - loginIfNeeded({ timeout: 20000 }); + }); + + beforeEach(() => { + loginIfNeeded({ timeout: 60000 }); }); it("Switch to Default Grants Program if available", () => { diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts index 4eff6302d0..eb14581527 100644 --- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts @@ -36,6 +36,33 @@ describe("Send an email", () => { const loginPage = LoginPageInstance(); const navPage = NavigationPageInstance(); + // The "Template Applied" swal2 dialog (see "Select Email Template") fires + // asynchronously and can land after that test has already finished waiting + // for it, leaking into whichever test runs next and blocking every field + // underneath it. Cheap and idempotent — call at the top of each subsequent + // test to mop up a straggler if one shows up. + function dismissStrayModalIfPresent() { + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + const modal = $body.find(".swal2-popup, .modal.show"); + if (modal.length === 0) { + return; + } + + const dismissButton = modal + .find("button") + .filter((_, element) => { + return /^(apply template|confirm|apply|ok)$/i.test( + (element.textContent || "").trim(), + ); + }) + .first(); + + if (dismissButton.length > 0) { + cy.wrap(dismissButton).click({ force: true }); + } + }); + } + function openSavedEmailFromHistoryBySubject(subject: string) { cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { const historyTableById = $body.find("#EmailHistoryTable"); @@ -156,7 +183,6 @@ describe("Send an email", () => { }); it("Open Emails tab", () => { - // Dismiss any swal2 modal that may be covering the tab cy.get("body").then(($body) => { if ($body.find(".swal2-container").length > 0) { cy.get(".swal2-container").then(($swal) => { @@ -175,114 +201,153 @@ describe("Send an email", () => { }); cy.get("#emails-tab", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") .click(); - cy.contains("Emails", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#emails-tab", { timeout: STANDARD_TIMEOUT }).should( + "have.class", + "active", + ); cy.contains("Email History", { timeout: STANDARD_TIMEOUT }).should("exist"); }); it("Open New Email form", () => { cy.get("#btn-new-email", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") .click(); - cy.contains("Email To", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }).should("be.visible"); }); it("Select Email Template", () => { - cy.get("#template", { timeout: STANDARD_TIMEOUT }) - .should("exist") + // UAT/PROD are still on the pre-redesign composer (#template, no + // confirmation dialogs); DEV/TEST have the newer TinyMCE toolbar select + // (no id, only this title attribute) plus a two-step swal2 confirmation. + // Match either selector so this spec works across all four environments. + cy.get('select[title="Select a template to apply"], #template', { + timeout: STANDARD_TIMEOUT, + }) .should("be.visible") - .select(TEMPLATE_NAME); + .select(TEMPLATE_NAME, { force: true }); - cy.get("#template") - .find("option:selected") - .should("have.text", TEMPLATE_NAME); + // Only the newer UI pops the "Apply Template?" / "Template Applied" + // swal2 confirmations — skip them if they don't show up. + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + if ($body.find(".swal2-popup").length > 0) { + cy.contains(".swal2-popup button", "Apply Template", { + timeout: STANDARD_TIMEOUT, + }).click(); + + // Once its async content fetch resolves, the app shows a second, + // separate "Template Applied" success dialog (OK button only). + cy.contains(".swal2-popup button", "OK", { + timeout: STANDARD_TIMEOUT, + }).click(); + } + }); // #EmailBody is a hidden textarea backing the rich-text editor. // Template selection populates the visible RTE but does not auto-sync // the backing field — trigger the change manually if still empty. cy.get("#EmailBody", { timeout: STANDARD_TIMEOUT }).then(($el) => { if (($el.val() as string).trim() === "") { - cy.wrap($el).invoke("val", "Test email body").trigger("change"); + cy.wrap($el) + .invoke("val", "Test email body") + .trigger("change", { force: true }); } }); }); it("Set Email To address", () => { + dismissStrayModalIfPresent(); + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_TO); + .clear({ force: true }) + .type(TEST_EMAIL_TO, { force: true }); cy.get("#EmailTo").should("have.value", TEST_EMAIL_TO); }); it("Set Email CC address", () => { + dismissStrayModalIfPresent(); + cy.get("#EmailCC", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_CC); + .clear({ force: true }) + .type(TEST_EMAIL_CC, { force: true }); cy.get("#EmailCC").should("have.value", TEST_EMAIL_CC); }); it("Set Email BCC address", () => { + dismissStrayModalIfPresent(); + + // The BCC row (#bcc-input-row) is hidden until the BCC toggle is clicked. + cy.get("body", { timeout: STANDARD_TIMEOUT }).then(($body) => { + const bccRow = $body.find("#bcc-input-row"); + if (bccRow.length > 0 && !Cypress.$(bccRow[0]).is(":visible")) { + cy.get("#btn-show-bcc").click(); + } + }); + cy.get("#EmailBCC", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_BCC); + .clear({ force: true }) + .type(TEST_EMAIL_BCC, { force: true }); cy.get("#EmailBCC").should("have.value", TEST_EMAIL_BCC); }); it("Set Email Subject", () => { + dismissStrayModalIfPresent(); + cy.get("#EmailSubject", { timeout: STANDARD_TIMEOUT }) - .should("exist") .should("be.visible") - .clear() - .type(TEST_EMAIL_SUBJECT); + .clear({ force: true }) + .type(TEST_EMAIL_SUBJECT, { force: true }); cy.get("#EmailSubject").should("have.value", TEST_EMAIL_SUBJECT); }); it("Save the email", () => { - cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }) - .should("exist") + dismissStrayModalIfPresent(); + + cy.get("#btn-save-top, #btn-save", { timeout: STANDARD_TIMEOUT }) .scrollIntoView() .should("be.visible") - .click(); + .click({ force: true }); - cy.get("#btn-new-email", { timeout: STANDARD_TIMEOUT }).should( - "be.visible", - ); + cy.contains("#EmailHistoryTable td", TEST_EMAIL_SUBJECT, { + timeout: STANDARD_TIMEOUT, + }).should("exist"); }); it("Select saved email from Email History", () => { + dismissStrayModalIfPresent(); openSavedEmailFromHistoryBySubject(TEST_EMAIL_SUBJECT); + // Clicking the history row scrolled the table into view, not the + // composer above it — scroll back up before checking field visibility. + cy.get("#EmailForm", { timeout: STANDARD_TIMEOUT }).scrollIntoView(); + cy.get("#EmailTo", { timeout: STANDARD_TIMEOUT }).should("be.visible"); cy.get("#EmailCC").should("be.visible"); cy.get("#EmailBCC").should("be.visible"); cy.get("#EmailSubject").should("be.visible"); - cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }).should("exist"); - cy.get("#btn-save", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#btn-send-top, #btn-send", { timeout: STANDARD_TIMEOUT }).should("exist"); + cy.get("#btn-save-top, #btn-save", { timeout: STANDARD_TIMEOUT }).should("exist"); }); it("Send the email", () => { - cy.get("#btn-send", { timeout: STANDARD_TIMEOUT }) - .should("exist") + dismissStrayModalIfPresent(); + + cy.get("#btn-send-top, #btn-send", { timeout: STANDARD_TIMEOUT }) .scrollIntoView() .should("be.visible") .should("not.be.disabled") - .click(); + .click({ force: true }); }); it("Confirm send email in dialog", () => { diff --git a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts index 4ef0eda373..3a73fef29b 100644 --- a/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts +++ b/applications/Unity.AutoUI/cypress/e2e/lists.cy.ts @@ -92,7 +92,7 @@ describe('Grant Manager Login and List Navigation', () => { cy.get('#applicationStatusChart text', { timeout: 30000 }) .first() .should(($el) => { - expect(parseInt($el.text(), 10)).to.be.gt(0) + expect(Number.parseInt($el.text(), 10)).to.be.gt(0) }) cy.visit(Cypress.env('webapp.url')) diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts index 1115fb4967..4060f51d92 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsRightTabPage.ts @@ -193,7 +193,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.emails, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } /** @@ -203,7 +203,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.comments, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } /** @@ -213,7 +213,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.attachments, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } /** @@ -223,7 +223,7 @@ export class ApplicationDetailsRightTabPage extends BasePage { return cy .get(this.countBadges.links, { timeout: this.STANDARD_TIMEOUT }) .invoke("text") - .then((text) => parseInt(text, 10) || 0); + .then((text) => Number.parseInt(text, 10) || 0); } // ============ Details Tab Methods ============ diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts index 52c129791a..5dc10d1b41 100644 --- a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts @@ -338,7 +338,7 @@ export class ApplicationsListPage extends ApplicationsPage { const titles: string[] = Cypress.$($els) .toArray() .map((el: HTMLElement) => - (el.textContent || "").replace(/\s+/g, " ").trim(), + (el.textContent || "").replaceAll(/\s+/g, " ").trim(), ) .filter((t: string) => t.length > 0); return titles; diff --git a/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts b/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts index 62d7106df7..04d39e1b3a 100644 --- a/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts +++ b/applications/Unity.AutoUI/cypress/pages/DashboardPage.ts @@ -114,7 +114,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.applicationStatusChart) .invoke("text") .then((text) => { - const number = parseInt(text, 10); + const number = Number.parseInt(text, 10); expect(number).to.be.gt(0); }); } @@ -126,7 +126,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.economicRegionChart) .invoke("text") .then((text) => { - const number = parseInt(text, 10); + const number = Number.parseInt(text, 10); expect(number).to.be.gt(0); }); } @@ -138,7 +138,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.applicationAssigneeChart) .invoke("text") .then((text) => { - const number = parseInt(text, 10); + const number = Number.parseInt(text, 10); expect(number).to.be.gt(0); }); } @@ -150,7 +150,7 @@ export class DashboardPage extends BasePage { this.getElement(this.chartSelectors.subsectorRequestedAmountChart) .invoke("text") .then((text) => { - const amount = parseFloat(text.replace("$", "")); + const amount = Number.parseFloat(text.replace("$", "")); expect(amount).to.be.gt(0); }); } @@ -171,7 +171,7 @@ export class DashboardPage extends BasePage { getChartValue(chartSelector: string): Cypress.Chainable { return this.getElement(chartSelector) .invoke("text") - .then((text) => parseInt(text, 10)); + .then((text) => Number.parseInt(text, 10)); } /** @@ -180,6 +180,6 @@ export class DashboardPage extends BasePage { getChartCurrencyValue(chartSelector: string): Cypress.Chainable { return this.getElement(chartSelector) .invoke("text") - .then((text) => parseFloat(text.replace("$", ""))); + .then((text) => Number.parseFloat(text.replace("$", ""))); } } diff --git a/applications/Unity.AutoUI/cypress/pages/ListPages.ts b/applications/Unity.AutoUI/cypress/pages/ListPages.ts index d58131d1de..4694e43a88 100644 --- a/applications/Unity.AutoUI/cypress/pages/ListPages.ts +++ b/applications/Unity.AutoUI/cypress/pages/ListPages.ts @@ -543,8 +543,8 @@ export class ApplicationsPage extends ListPage { .find(`td:nth-child(${this.columns.requestedAmount + 1})`) .text() .trim(); - const amount = parseFloat(amountText.replace(/[$,]/g, "")); - if (!isNaN(amount)) { + const amount = Number.parseFloat(amountText.replaceAll(/[$,]/g, "")); + if (!Number.isNaN(amount)) { total += amount; } }) diff --git a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts index 3cc858c15e..9e58c6ddeb 100644 --- a/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts +++ b/applications/Unity.AutoUI/cypress/regression/ApprovalFlow.cy.ts @@ -114,7 +114,7 @@ const APPLICATIONS_PATH = "GrantApplications"; dismissBlockingModalIfPresent(); listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId); @@ -155,7 +155,6 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.get("#nav-payment-info-tab").should("have.class", "active"); detailsPage.dismissErrorModalIfPresent(); - // Intercept the Refresh Site List API call and wait for it to complete cy.intercept("GET", "**/api/app/supplier/sites-by-supplier-number**").as("siteRefresh"); detailsPage.clickRefreshSiteList(); cy.wait("@siteRefresh"); @@ -168,15 +167,28 @@ const APPLICATIONS_PATH = "GrantApplications"; } function openStatusActionsMenu(): void { - waitForBlockingUiToClear(); + // Dismiss any transient error modal first — waitForBlockingUiToClear() only + // waits for blocking UI to go away on its own, it never clicks anything, so + // an error modal that requires a click to close would hang it until timeout. detailsPage.dismissErrorModalIfPresent(); + waitForBlockingUiToClear(); + + // On DEV the button can re-render (e.g. "Processing..." -> its real label) + // between assertions, detaching the subject held by a single chained + // .should().and(). Re-querying fresh for each assertion (per Cypress's + // own guidance for this error) picks up the current DOM node instead. + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .scrollIntoView(); + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) .filter(":visible") .first() - .scrollIntoView() .should("be.visible") .and("not.contain.text", "Processing...") .click({ force: true }); + cy.get(STATUS_ACTIONS.menu, { timeout: 20000 }).should("be.visible"); } @@ -213,14 +225,13 @@ const APPLICATIONS_PATH = "GrantApplications"; } function confirmStatusActionIfNeeded(): void { - cy.wait(500); - cy.get("body").then(($body) => { + const confirmIfPresent = ($body: JQuery): boolean => { if ($body.find(".swal2-popup .swal2-confirm").length > 0) { cy.get(".swal2-popup .swal2-confirm", { timeout: 20000 }) .should("be.visible") .click({ force: true }); cy.get(".swal2-container", { timeout: 20000 }).should("not.exist"); - return; + return true; } if ( @@ -238,7 +249,21 @@ const APPLICATIONS_PATH = "GrantApplications"; timeout: 20000, }).should("not.exist"); cy.get(".modal-backdrop", { timeout: 20000 }).should("not.exist"); + return true; } + + return false; + }; + + cy.get("body").then(($body) => { + if (confirmIfPresent($body)) { + return; + } + + cy.wait(750); + cy.get("body").then(($bodyAfterGracePeriod) => { + confirmIfPresent($bodyAfterGracePeriod); + }); }); } @@ -315,14 +340,9 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.get("#ApprovalView_ApprovedAmount", { timeout: 30000 }) .should("be.visible") .and("not.be.disabled"); + reviewPage.enterApprovedAmount(TEST_CONFIG.approvedAmount); cy.get("body").then(($body) => { - if ($body.find("#ApprovalView_ApprovedAmount").length > 0) { - reviewPage.enterApprovedAmount(TEST_CONFIG.approvedAmount); - } else { - cy.log("Approved amount field not present yet; skipping amount entry"); - } - if ($body.find("#ApprovalView_FinalDecisionDate").length > 0) { reviewPage.setDecisionDateToToday(); } else { @@ -342,7 +362,7 @@ const APPLICATIONS_PATH = "GrantApplications"; listPage .waitForNoBlockingOverlay() - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId) .selectRowByText(submissionId); @@ -411,6 +431,9 @@ const APPLICATIONS_PATH = "GrantApplications"; /** Select the submissionId row and open the Approve Payments modal. */ function selectRowAndOpenApproveModal(): void { + // Same ordering as openStatusActionsMenu() — dismiss any transient error + // modal before the passive wait, since the wait never clicks anything. + detailsPage.dismissErrorModalIfPresent(); waitForBlockingUiToClear(); cy.contains("tr", submissionId, { timeout: 20000 }) .scrollIntoView() @@ -501,7 +524,7 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Search for submission", () => { expect(submissionId, "Submission ID should be set").to.exist; listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId); }); @@ -520,7 +543,7 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.log("Already on details page after assignment"); } else { listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId) .selectRowByText(submissionId) @@ -572,8 +595,7 @@ const APPLICATIONS_PATH = "GrantApplications"; cy.get("body").then(($body) => { if ($body.find("#CreateButton").length > 0) { cy.get("#CreateButton").click({ force: true }); - // Give the new assessment row time to render before subsequent actions. - cy.wait(1000); // Needed because row creation animation can delay DOM readiness. + cy.wait(1000); // Row creation animation can delay DOM readiness. } else { cy.log("Create Assessment button not found - may already be created"); } @@ -588,7 +610,9 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Configure payment info", () => { cy.reload(); // Reload to get fresh data and avoid concurrency issues - // Wait briefly for async payment tab dependencies to stabilize after reload. + // Every other cy.reload() in this spec is followed by this — a transient + // auth/session error modal can appear post-reload and block the form below. + detailsPage.dismissErrorModalIfPresent(); cy.wait(2000); // Prevents save attempts before payment controls are initialized. detailsPage .goToPaymentInfoTab() @@ -615,7 +639,7 @@ const APPLICATIONS_PATH = "GrantApplications"; // Skip gracefully if the supplier has no site data in this environment cy.get("body").then(($body) => { const rows = $body.find("#SiteInfoTable tbody tr"); - const firstRowText = rows.first().text().replace(/\s+/g, " ").trim(); + const firstRowText = rows.first().text().replaceAll(/\s+/g, " ").trim(); const hasTokenError = $body.text().includes("GetAuthTokenAsync") || $body.text().includes("Error retrieving Token"); @@ -690,17 +714,40 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Test approval workflow (confirm)", () => { cy.reload(); // Refresh to ensure all changes are reflected before approval detailsPage.dismissErrorModalIfPresent(); - clickStatusAction(STATUS_ACTIONS.completeAssessment); - confirmStatusActionIfNeeded(); + waitForBlockingUiToClear(); - cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) - .filter(":visible") - .first() + cy.get(BREADCRUMB_STATUS_SELECTOR, { timeout: 20000 }) .should("be.visible") - .and("not.contain.text", "Processing..."); + .invoke("text") + .then((statusText) => { + const currentStatus = statusText.trim().toLowerCase(); - clickStatusAction(STATUS_ACTIONS.approve); - detailsPage.waitForConfirmModal().clickConfirm(); + if (currentStatus === "approved") { + cy.log("Application is already approved"); + return; + } + + clickStatusActionIfEnabled( + STATUS_ACTIONS.completeAssessment, + "Complete Assessment", + ); + + cy.get(STATUS_ACTIONS.menuButton, { timeout: 20000 }) + .filter(":visible") + .first() + .should("be.visible") + .and("not.contain.text", "Processing..."); + + clickStatusAction(STATUS_ACTIONS.approve); + confirmStatusActionIfNeeded(); + }); + + cy.get(BREADCRUMB_STATUS_SELECTOR, { timeout: 60000 }) + .should("be.visible") + .invoke("text") + .should((statusText) => { + expect(statusText.trim().toLowerCase()).to.equal("approved"); + }); }); // ============ Post-Approval Verification ============ @@ -713,7 +760,7 @@ const APPLICATIONS_PATH = "GrantApplications"; it("Verify application status is Approved", () => { expect(submissionId, "Submission ID should be set").to.exist; listPage - .selectQuickDateRange("last7days") + .selectQuickDateRange("last30days") .waitForTableRefresh() .searchForSubmission(submissionId); diff --git a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts index d04fa560c4..0b7124aaf0 100644 --- a/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts +++ b/applications/Unity.AutoUI/cypress/scripts/chefs-api-submission.cy.ts @@ -159,18 +159,13 @@ function completeChefsLogin(environment: ChefsEnvironment, timeout: number): voi cy.visit(`${environment.baseURL}/app`); - cy.get("#app > div > main > header > header > div > div.d-print-none", { - timeout, - }) - .should("exist") - .click(); - - cy.get( - "#app > div > main > div.v-container.v-locale--is-ltr.text-center.main > div > div:nth-child(2) > div > button", - { timeout }, - ) - .should("exist") - .click(); + // The header auth button hydrates asynchronously after an auth-state check; + // wait for its label rather than just its (empty) wrapper to exist. + cy.get("#loginButton", { timeout }).should("be.visible").click(); + + // CHEFS shows an identity-provider picker (IDIR / IDIR MFA / BC Services + // Card / BCeID); each button carries a stable data-test attribute. + cy.get('[data-test="idir"]', { timeout }).should("be.visible").click(); waitForIdentityRedirectOrAuthenticatedChefsPage(environment.baseURL, timeout); diff --git a/applications/Unity.AutoUI/cypress/support/auth.ts b/applications/Unity.AutoUI/cypress/support/auth.ts index bd83e88c50..aa47482b8a 100644 --- a/applications/Unity.AutoUI/cypress/support/auth.ts +++ b/applications/Unity.AutoUI/cypress/support/auth.ts @@ -39,6 +39,48 @@ function isLoginPage($body: JQuery): boolean { return $body.find('button:contains("LOGIN")').length > 0; } +function hasCredentialForm($body: JQuery): boolean { + return ( + $body.find("#user, input[name='user'], input[name='username']").length > 0 && + $body.find("#password, input[name='password'], input[type='password']").length > 0 + ); +} + +function hasViewApplicationsButton($body: JQuery): boolean { + return $body.find('button:contains("VIEW APPLICATIONS")').length > 0; +} + +function waitForCredentialFormOrAuthenticatedPage(timeout: number): void { + cy.get("body", { timeout }).should(($body) => { + const pathname = $body[0]?.ownerDocument?.location?.pathname ?? ""; + + const isReady = + pathname.includes("/GrantApplications") || + hasViewApplicationsButton($body) || + hasCredentialForm($body); + + expect( + isReady, + `expected credential form, VIEW APPLICATIONS button, or /GrantApplications. Current path: ${pathname}`, + ).to.equal(true); + }); +} + +function getExistingSelector( + $body: JQuery, + selectors: string[], +): string { + const selector = selectors.find((candidate) => $body.find(candidate).length > 0); + + if (!selector) { + throw new Error( + `None of the expected selectors were found: ${selectors.join(", ")}`, + ); + } + + return selector; +} + /** * Handles the Keycloak IDIR selection and login form */ @@ -69,36 +111,53 @@ function handleKeycloakLogin( } }); + waitForCredentialFormOrAuthenticatedPage(timeout); + // Handle username/password form if it appears cy.get("body", { timeout }).then(($loginBody) => { - if ($loginBody.find("#user").length > 0) { - cy.log("Entering IDIR credentials"); - - const username = options.username || Cypress.env("test1username"); - const password = options.password || Cypress.env("test1password"); - - cy.get("#user", { timeout }) - .should("be.visible") - .type(username, { log: false }); - - cy.get("#password", { timeout }) - .should("be.visible") - .type(password, { log: false }); - - // Look for Continue button or submit the form - cy.get("body").then(($formBody) => { - if ($formBody.find('button:contains("Continue")').length > 0) { - cy.contains("button", "Continue", { timeout }).click(); - } else if ($formBody.find("input[type='submit']").length > 0) { - cy.get("input[type='submit']", { timeout }).click(); - } else { - cy.log("⚠️ No submit button found, attempting form submission"); - cy.get("#user").parents("form").submit(); - } - }); - } else { + if (!hasCredentialForm($loginBody)) { cy.log("✓ Already authenticated, skipping credentials"); + return; } + + cy.log("Entering IDIR credentials"); + + const username = options.username || Cypress.env("test1username"); + const password = options.password || Cypress.env("test1password"); + const usernameSelector = getExistingSelector($loginBody, [ + "#user", + "input[name='user']", + "input[name='username']", + ]); + const passwordSelector = getExistingSelector($loginBody, [ + "#password", + "input[name='password']", + "input[type='password']", + ]); + + cy.get(usernameSelector, { timeout }) + .should("be.visible") + .clear() + .type(username, { log: false }); + + cy.get(passwordSelector, { timeout }) + .should("be.visible") + .clear() + .type(password, { log: false }); + + // Look for Continue button or submit the form + cy.get("body").then(($formBody) => { + if ($formBody.find('button:contains("Continue")').length > 0) { + cy.contains("button", "Continue", { timeout }).click(); + } else if ($formBody.find("input[type='submit']").length > 0) { + cy.get("input[type='submit']", { timeout }).click(); + } else if ($formBody.find("button[type='submit']").length > 0) { + cy.get("button[type='submit']", { timeout }).click(); + } else { + cy.log("⚠️ No submit button found, attempting form submission"); + cy.get(usernameSelector).parents("form").submit(); + } + }); }); } diff --git a/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts b/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts index f725161b42..cfa55d6aa8 100644 --- a/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts +++ b/applications/Unity.AutoUI/cypress/utilities/TestDataHelper.ts @@ -81,7 +81,7 @@ export class TestDataHelper { * Parse currency string to number */ static parseCurrency(currencyString: string): number { - return parseFloat(currencyString.replace(/[$,]/g, "")); + return Number.parseFloat(currencyString.replaceAll(/[$,]/g, "")); } /** diff --git a/applications/Unity.GrantManager/.env.example b/applications/Unity.GrantManager/.env.example index c937e86596..42e07659d4 100644 --- a/applications/Unity.GrantManager/.env.example +++ b/applications/Unity.GrantManager/.env.example @@ -54,7 +54,7 @@ AuthServer__OidcSignoutCallback="http://localhost:44342/signout-callback-oidc" ##S3__SecretAccessKey="******************" ##S3__ApplicationS3Folder="Unity/Application" ##S3__AssessmentS3Folder="Unity/Adjudication" -##S3__DisallowedFileTypes="[ "exe" , "sh" , "ksh" , "bat" , "cmd" ]" +##S3__AllowedFileTypes=["pdf","doc","docx","xls","xlsx","ppt","pptx","jpg","jpeg","png","gif","txt","csv","zip","odt","ods","odp","rtf","bmp","tif","tiff","webp","heic","heif","eml","msg"] ##S3__MaxFileSize="25" ##S3__EmailAttachmentMaxFileSize="20" ##S3__EmailAttachmentsTotalMaxFileSize="25" diff --git a/applications/Unity.GrantManager/Directory.Build.props b/applications/Unity.GrantManager/Directory.Build.props index 4239024ce7..0e9f5007d6 100644 --- a/applications/Unity.GrantManager/Directory.Build.props +++ b/applications/Unity.GrantManager/Directory.Build.props @@ -10,7 +10,7 @@ - + diff --git a/applications/Unity.GrantManager/common.props b/applications/Unity.GrantManager/common.props index bdf9a7442f..e28f59fbc5 100644 --- a/applications/Unity.GrantManager/common.props +++ b/applications/Unity.GrantManager/common.props @@ -19,7 +19,7 @@ - + \ No newline at end of file diff --git a/applications/Unity.GrantManager/docker-compose.yml b/applications/Unity.GrantManager/docker-compose.yml index a3015c37e3..720f9574d6 100644 --- a/applications/Unity.GrantManager/docker-compose.yml +++ b/applications/Unity.GrantManager/docker-compose.yml @@ -146,6 +146,27 @@ services: networks: - common-network + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./scripts/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./scripts/prometheus/alert-rules.yml:/etc/prometheus/alert-rules.yml:ro + depends_on: + - unity-grantmanager-web + networks: + - common-network + + alertmanager: + image: prom/alertmanager:latest + ports: + - "9093:9093" + volumes: + - ./scripts/prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + networks: + - common-network + volumes: postgres_data: redis_volume_data: diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md new file mode 100644 index 0000000000..8b8537d6b0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/README.md @@ -0,0 +1,14 @@ +# Unity.AI Docs + +## Architecture +- [`index.md`](./index.md) +- [`flow-map.md`](./flow-map.md) +- [`prompt-map.md`](./prompt-map.md) +- [`implementation-playbook.md`](./implementation-playbook.md) + +## Operations +- [`operations/application-analysis.md`](./operations/application-analysis.md) +- [`operations/attachment-summary.md`](./operations/attachment-summary.md) +- [`operations/application-scoring.md`](./operations/application-scoring.md) +- [`operations/form-mapping.md`](./operations/form-mapping.md) +- [`operations/form-worksheet.md`](./operations/form-worksheet.md) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md new file mode 100644 index 0000000000..42bd018836 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/flow-map.md @@ -0,0 +1,14 @@ +# Flow Map + +## Standard path +UI -> API app service -> queue -> background job -> AI runtime -> persisted result + +## Operation families +- Application Analysis: submission -> analysis +- Attachment Summary: attachment ids -> summaries +- Application Scoring: application + scoresheet -> scoring +- Form Mapping: form version -> mapping +- Form Worksheet: form version -> worksheet + +## Build Rule +See [`implementation-playbook.md`](./implementation-playbook.md) for the canonical add-a-new-operation sequence. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md new file mode 100644 index 0000000000..9a9a50a207 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/implementation-playbook.md @@ -0,0 +1,73 @@ +# AI Operation Implementation Playbook + +## Purpose +Use this when adding a new AI operation. Start with the bare minimum and only add optional pieces when the operation needs them. + +Use these existing operations as the canonical references: + +1. `ApplicationAnalysis` +2. `ApplicationScoring` +3. `AttachmentSummary` +4. `FormMapping` +5. `FormWorksheet` + +## Base Pattern +1. Define the prompt type. +2. Add the v2 prompt seed. +3. Add the operation seed. +4. Add the runtime contract method. +5. Add the runtime implementation. +6. Add the app service or queue entry. +7. Add the background job only if the result must be applied or persisted. +8. Add the UI button and status polling only if users trigger the operation from the web app. +9. Add tests for the prompt, runtime parsing, and job or service path. + +## Bare Minimum +For the first pass, only add what is required for a working operation: + +- prompt type +- prompt seed +- operation seed +- runtime method +- queue/app service entry +- job or direct apply path, if needed + +## Optional Pieces +Add these only when the operation needs them: + +- feature flag +- permissions +- permission definition provider entries +- menu entry +- UI button +- status polling +- refresh-after-complete behavior +- persistence/import/publish/assign behavior + +## Rules +- Keep the prompt as the source of truth. +- Reuse the existing async generation pattern. +- Do not hardcode field buckets or response shapes in UI code. +- Do not invent new plumbing if an existing operation already does the same job. +- Do not add tenant feature seeding. +- Do not add write-back UI behavior unless the operation already persists output. + +## Expected Flow +1. User clicks Generate. +2. UI disables the button and shows generating state, if the operation has UI. +3. API checks permission and feature flag, if the operation uses them. +4. API queues the generation request. +5. Background job loads the operation context. +6. Job builds the prompt payload from existing data. +7. AI runtime renders v2 prompts and logs input/output. +8. Job parses the AI response. +9. Job applies the result if needed. +10. Job stamps status and rate limit state. +11. UI polls status and refreshes after completion, if applicable. + +## Validation +- Confirm the prompt version is v2. +- Confirm the operation exists in the AI operation seed. +- Confirm any required feature flag exists in the host feature definitions. +- Confirm any required permission is wired in the permission definition provider. +- Confirm the UI button uses the same generating/status flow as the other operations, if it is user-triggered. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md new file mode 100644 index 0000000000..768b23b3c2 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/index.md @@ -0,0 +1,80 @@ +# Unity.AI Index + +## Domain.Shared +AI constants: +- feature flags +- permission names +- localization keys +- prompt type names + +## Application.Contracts +Public AI surface: +- app service interfaces +- queue interfaces +- DTOs +- permission definitions + +## Application +AI implementation: +- runtime +- prompt seeding +- generation app services +- validators +- prompt logging + +## Web +UI-facing AI bits: +- menus +- generation buttons +- status polling + +## Files +### Application +- `AI/Operations` - validators and helpers +- `AI/Runtime` - rendering, parsing, logging, provider calls +- `AI/Prompts` - prompt types and template plumbing +- `DataSeed` - seeded prompt and operation data +- `Generation/AIGenerationAppService.cs` - generation API + +### Application.Contracts +- `AI/IAIService.cs` - runtime contract +- `Generation/IAIGenerationAppService.cs` - generation app service contract +- `Generation/*ResultDto.cs` - queued result DTOs +- `AI/Operations/IAIGenerationPrerequisiteValidator.cs` - queue prerequisites +- `Automation/IApplicationAIGenerationQueue.cs` - queue contract +- `Permissions/*` - permissions + +### Domain.Shared +- `Features/AIFeatures.cs` - feature flags +- `Localization/AILocalizationKeys.cs` - messages +- `PromptTypes/AIPromptTypes.cs` - prompt family names + +### Web +- `Menus/AIMenuContributor.cs` - menu entries +- `Menus/AIMenus.cs` - menu item names + +## Access +| Operation | View | Generate | +| --- | --- | --- | +| Application Analysis | `ViewApplicationAnalysis` | `GenerateApplicationAnalysis` | +| Attachment Summary | `ViewAttachmentSummary` | `GenerateAttachmentSummaries` | +| Application Scoring | `ViewScoringResult` | `GenerateScoring` | +| Form Mapping | `ViewFormMapping` | `GenerateFormMapping` | +| Form Worksheet | `ViewFormWorksheet` | `GenerateFormWorksheet` | + +- Features: + - `Unity.AI.ApplicationAnalysis` + - `Unity.AI.AttachmentSummaries` + - `Unity.AI.Scoring` + - `Unity.AI.FormMapping` + - `Unity.AI.FormWorksheet` + +- Rule: + - Both permission and feature gate must allow generation. + +## AI Notes +- Prompt logging: logs rendered system/user prompts and provider output. +- Response parsing: parses provider output into stable app-facing results. +- Feature gating: disabled features fail early at the API boundary. +- Background jobs: mark failures, then re-throw. +- New operation playbook: see `implementation-playbook.md`. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md new file mode 100644 index 0000000000..1b2bfdb194 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-analysis.md @@ -0,0 +1,19 @@ +# Application Analysis + +## Goal +Generate an AI analysis of an application submission. + +## Inputs +- Application submission data +- Application context +- Optional attachments, when present + +## Surface +- `POST /api/app/ai/generation/application-analysis` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured analysis output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- This is a reviewer-oriented summary and recommendation flow. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md new file mode 100644 index 0000000000..a19e1f8bb1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/application-scoring.md @@ -0,0 +1,20 @@ +# Application Scoring + +## Goal +Generate scored answers for a submitted application against an assigned scoresheet. + +## Inputs +- Application submission data +- Assigned scoresheet +- Scoresheet questions and definitions + +## Surface +- `POST /api/app/ai/generation/application-scoring` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured scoring output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- The prompt asks for answers only for the configured section or scoresheet context. +- The parsed output must align with the scoresheet question ids. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md new file mode 100644 index 0000000000..5735c3996f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/attachment-summary.md @@ -0,0 +1,18 @@ +# Attachment Summary + +## Goal +Generate summaries for selected application attachments. + +## Inputs +- One or more attachment IDs +- Application context + +## Surface +- `POST /api/app/ai/generation/attachment-summary` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured attachment summary output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Notes +- Each attachment is processed as part of the generation request. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md new file mode 100644 index 0000000000..2881cefe9f --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-mapping.md @@ -0,0 +1,32 @@ +# Form Mapping + +## Goal +Generate recommended CHEFS-to-Unity field mapping for a form version. + +## Inputs +- CHEFS fields from the form version +- Unity core intake fields +- Worksheet-derived custom fields when available + +## Rule +- Prefer existing Unity core intake fields where they already fit the source field. +- Only suggest worksheet fields or worksheet creation when the form genuinely needs them. + +## Surface +- `POST /api/app/ai/generation/form-mapping` +- `GET /api/app/ai/generation/status` +- `GET /api/app/application-form-version/{id}` + +## Contract +- Structured mapping recommendation JSON output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Output Shape +- Core field matches. +- Worksheet field matches. +- Worksheet creation suggestions. +- Issues or conflicts. +- Keep the result valid JSON and compatible with the mapping page flow. + +## Notes +- The AI response is expected to stay structured and JSON-shaped. +- For new operations, follow [`implementation-playbook.md`](../implementation-playbook.md). diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md new file mode 100644 index 0000000000..49d76dd9df --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/operations/form-worksheet.md @@ -0,0 +1,29 @@ +# Form Worksheet + +## Goal +Generate a recommended worksheet definition for a form version. + +## Inputs +- Form version context +- Form name +- Existing worksheet links +- Worksheet field context + +## Rule +- Prefer existing Unity core fields when they already fit the need. +- Only add new worksheet fields when the form genuinely needs extra Unity fields. + +## Surface +- `POST /api/app/ai/generation/form-worksheet` +- `GET /api/app/ai/generation/status` + +## Contract +- Structured Flex worksheet JSON output. Returns an immediate queued result via API app service, queue, background job, and AI runtime. + +## Output Shape +- Full worksheet definition JSON. +- Include only additional worksheet fields that the form needs beyond core Unity fields. +- Keep the result valid JSON and compatible with Flex import. + +## Notes +- The AI output should stay valid JSON. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md b/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md new file mode 100644 index 0000000000..c7d544c1f1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/docs/prompt-map.md @@ -0,0 +1,22 @@ +# Prompt Map + +## Prompt families +- `ApplicationAnalysis` - review and recommendation +- `AttachmentSummary` - attachment summary +- `ApplicationScoring` - question scoring +- `FormMapping` - CHEFS to Unity mapping +- `FormWorksheet` - worksheet generation + +## Versions +- `v0`, `v1`, `v2` live under `AI/Prompts/Versions` +- The seeder loads built-in prompt rows from those versions +- Runtime selects by prompt family and version + +## Prompt rules +- Versioned prompts are the source of truth. +- Prompt templates define the request shape. +- Structured outputs should stay JSON-shaped. +- New versions should not silently change behavior. + +## Build Rule +Use [`implementation-playbook.md`](./implementation-playbook.md) when adding a new prompt-backed operation. 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 8caf69258d..565dca95cb 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,8 +10,10 @@ 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); + Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default); + Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default); + Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs index 67df324548..cbbfeae8ea 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Operations/IAIGenerationPrerequisiteValidator.cs @@ -10,4 +10,10 @@ public interface IAIGenerationPrerequisiteValidator Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId); Task EnsureApplicationScoringAvailableAsync(Guid applicationId); + + Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId); + + Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId); + + Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId); } 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 deleted file mode 100644 index 031acd1937..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/AttachmentSummaryBatchRequest.cs +++ /dev/null @@ -1,28 +0,0 @@ -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/Requests/FormMappingRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs new file mode 100644 index 0000000000..caed63764c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormMappingRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormMappingRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs new file mode 100644 index 0000000000..64ba94b28c --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormScoresheetRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormScoresheetRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs new file mode 100644 index 0000000000..290445b9a3 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Requests/FormWorksheetRequest.cs @@ -0,0 +1,13 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Unity.AI.Requests; + +public class FormWorksheetRequest +{ + [JsonPropertyName("data")] + public JsonElement Data { get; set; } + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { 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 deleted file mode 100644 index 4751fe9122..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/AttachmentSummaryBatchResponse.cs +++ /dev/null @@ -1,19 +0,0 @@ -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/AI/Responses/FormMappingFieldResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs new file mode 100644 index 0000000000..2ed0538b8a --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingFieldResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("isCustom")] + public bool IsCustom { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs new file mode 100644 index 0000000000..faa0e5eddb --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingIssueResponse.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingIssueResponse +{ + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs new file mode 100644 index 0000000000..d86116d2e0 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingMatchResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingMatchResponse +{ + [JsonPropertyName("sourceField")] + public string SourceField { get; set; } = string.Empty; + + [JsonPropertyName("targetField")] + public string TargetField { get; set; } = string.Empty; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonPropertyName("confidence")] + public decimal Confidence { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs new file mode 100644 index 0000000000..4015265699 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingResponse.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingResponse +{ + public string Mapping { get; set; } = string.Empty; + + [JsonPropertyName("coreFieldMatches")] + public List CoreFieldMatches { get; set; } = []; + + [JsonPropertyName("worksheetMatches")] + public List WorksheetMatches { get; set; } = []; + + [JsonPropertyName("worksheetCreationSuggestions")] + public List WorksheetCreationSuggestions { get; set; } = []; + + [JsonPropertyName("issues")] + public List Issues { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs new file mode 100644 index 0000000000..4d5e207b5e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormMappingWorksheetResponse.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormMappingWorksheetResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("fieldMatches")] + public List FieldMatches { get; set; } = []; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs new file mode 100644 index 0000000000..3ed8321221 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormScoresheetResponse.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormScoresheetResponse +{ + public string Scoresheet { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public uint Version { get; set; } = 1; + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("published")] + public bool Published { get; set; } + + [JsonPropertyName("reportColumns")] + public string ReportColumns { get; set; } = string.Empty; + + [JsonPropertyName("reportKeys")] + public string ReportKeys { get; set; } = string.Empty; + + [JsonPropertyName("reportViewName")] + public string ReportViewName { get; set; } = string.Empty; + + [JsonPropertyName("sections")] + public List Sections { get; set; } = []; +} + +public class FormScoresheetSectionResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("fields")] + public List Fields { get; set; } = []; +} + +public class FormScoresheetFieldResponse +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("label")] + public string Label { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("order")] + public uint Order { get; set; } + + [JsonPropertyName("type")] + public int Type { get; set; } + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("definition")] + public string? Definition { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs new file mode 100644 index 0000000000..9ecc8f3ac1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetCreationResponse.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Unity.AI.Responses; + +public class FormWorksheetCreationResponse +{ + [JsonPropertyName("worksheetName")] + public string WorksheetName { get; set; } = string.Empty; + + [JsonPropertyName("suggestedFields")] + public List SuggestedFields { get; set; } = []; + + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs new file mode 100644 index 0000000000..1ee1d837bc --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/AI/Responses/FormWorksheetResponse.cs @@ -0,0 +1,6 @@ +namespace Unity.AI.Responses; + +public class FormWorksheetResponse +{ + public string Worksheet { get; set; } = string.Empty; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs deleted file mode 100644 index e7c0dbbb6c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Automation/IApplicationAIGenerationQueue.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Unity.AI.Automation; - -public interface IApplicationAIGenerationQueue -{ - Task QueueAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null, List? attachmentIds = null); - Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); - Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); - Task QueueAllAIStagesAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); -} 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 deleted file mode 100644 index 3a832df03c..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationRequestDto.cs +++ /dev/null @@ -1,14 +0,0 @@ -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 index ca5684257c..11729873af 100644 --- 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 @@ -1,12 +1,47 @@ +using System; + namespace Unity.AI.Generation; public class AIGenerationStatusDto { - public AIGenerationStatusRequestDto? GenerationRequest { get; set; } + public AIGenerationRequestDto? GenerationRequest { get; set; } + + 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 IsGenerating { get; set; } + public bool IsActive { get; set; } +} + +public class AIGenerationRequestDto +{ + 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 int RetryAfterSeconds { get; set; } + public bool IsActive { 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 deleted file mode 100644 index b80e09f433..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AIGenerationStatusRequestDto.cs +++ /dev/null @@ -1,24 +0,0 @@ -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/AttachmentSummaryGenerationRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AttachmentSummaryGenerationRequestDto.cs new file mode 100644 index 0000000000..3fd80ac1fe --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/AttachmentSummaryGenerationRequestDto.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Unity.AI.Generation; + +public class AttachmentSummaryGenerationRequestDto +{ + [Required] + [JsonPropertyName("applicationId")] + public Guid ApplicationId { get; set; } + + [Required] + [JsonPropertyName("attachmentIds")] + public List AttachmentIds { get; set; } = []; + + [JsonPropertyName("promptVersion")] + public string? PromptVersion { get; set; } +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs deleted file mode 100644 index 82956d14de..0000000000 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Generation/GenerateAttachmentSummariesInputDto.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Unity.AI.Generation; - -public class GenerateAttachmentSummariesInputDto -{ - public Guid ApplicationId { get; set; } - - public List AttachmentIds { get; set; } = []; - - public string? PromptVersion { 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 c22118ed39..938dff7439 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 @@ -1,19 +1,22 @@ using System; -using System.Collections.Generic; using System.Threading.Tasks; -using Unity.GrantManager.Attachments; -using Unity.GrantManager.GrantApplications; using Volo.Abp.Application.Services; namespace Unity.AI.Generation; public interface IAIGenerationAppService : IApplicationService { - Task> GenerateAttachmentSummariesAsync(GenerateAttachmentSummariesInputDto input); + Task GenerateApplicationAttachmentSummariesAsync(AttachmentSummaryGenerationRequestDto request); - Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null); - Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null); + + Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + + Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null); + + Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, 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 3e4a2a7b9d..195bedf97b 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 @@ -55,6 +55,36 @@ public override void Define(IPermissionDefinitionContext context) L("Permission:AI.GenerateScoring")) .RequireFeatures("Unity.AI.Scoring"); + var viewFormMapping = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormMapping, + L("Permission:AI.ViewFormMapping")) + .RequireFeatures("Unity.AI.FormMapping"); + + viewFormMapping.AddChild( + AIPermissions.Analysis.GenerateFormMapping, + L("Permission:AI.GenerateFormMapping")) + .RequireFeatures("Unity.AI.FormMapping"); + + var viewFormWorksheet = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormWorksheet, + L("Permission:AI.ViewFormWorksheet")) + .RequireFeatures("Unity.AI.FormWorksheet"); + + viewFormWorksheet.AddChild( + AIPermissions.Analysis.GenerateFormWorksheet, + L("Permission:AI.GenerateFormWorksheet")) + .RequireFeatures("Unity.AI.FormWorksheet"); + + var viewFormScoresheet = aiPermissionsGroup.AddPermission( + AIPermissions.Analysis.ViewFormScoresheet, + L("Permission:AI.ViewFormScoresheet")) + .RequireFeatures("Unity.AI.FormScoresheet"); + + viewFormScoresheet.AddChild( + AIPermissions.Analysis.GenerateFormScoresheet, + L("Permission:AI.GenerateFormScoresheet")) + .RequireFeatures("Unity.AI.FormScoresheet"); + var settingManagement = context.GetGroup(SettingManagementPermissions.GroupName); var configureAI = settingManagement.AddPermission( AIPermissions.Configuration.ConfigureAI, @@ -62,7 +92,10 @@ public override void Define(IPermissionDefinitionContext context) configureAI.StateCheckers.Add(new AnyFeaturePermissionStateProvider( "Unity.AI.Scoring", "Unity.AI.AttachmentSummaries", - "Unity.AI.ApplicationAnalysis")); + "Unity.AI.ApplicationAnalysis", + "Unity.AI.FormMapping", + "Unity.AI.FormWorksheet", + "Unity.AI.FormScoresheet")); } private static LocalizableString L(string name) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs index b9ea59607f..0740d0978d 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Permissions/AIPermissions.cs @@ -15,12 +15,54 @@ public static class Reporting public static class Analysis { public const string ViewApplicationAnalysis = GroupName + ".ViewApplicationAnalysis"; - public const string ViewAttachmentSummary = GroupName + ".ViewAttachmentSummary"; - public const string ViewScoringResult = GroupName + ".ViewScoringResult"; + public const string ViewAttachmentSummary = GroupName + ".ViewAttachmentSummary"; + public const string ViewScoringResult = GroupName + ".ViewScoringResult"; + public const string ViewFormMapping = GroupName + ".ViewFormMapping"; + public const string ViewFormWorksheet = GroupName + ".ViewFormWorksheet"; + public const string ViewFormScoresheet = GroupName + ".ViewFormScoresheet"; public const string GenerateApplicationAnalysis = GroupName + ".GenerateApplicationAnalysis"; public const string GenerateAttachmentSummaries = GroupName + ".GenerateAttachmentSummaries"; - public const string GenerateScoring = GroupName + ".GenerateScoring"; + public const string GenerateScoring = GroupName + ".GenerateScoring"; + public const string GenerateFormMapping = GroupName + ".GenerateFormMapping"; + public const string GenerateFormWorksheet = GroupName + ".GenerateFormWorksheet"; + public const string GenerateFormScoresheet = GroupName + ".GenerateFormScoresheet"; + } + + public static class ApplicationAnalysis + { + public const string View = Analysis.ViewApplicationAnalysis; + public const string Generate = Analysis.GenerateApplicationAnalysis; + } + + public static class AttachmentSummaries + { + public const string View = Analysis.ViewAttachmentSummary; + public const string Generate = Analysis.GenerateAttachmentSummaries; + } + + public static class ApplicationScoring + { + public const string View = Analysis.ViewScoringResult; + public const string Generate = Analysis.GenerateScoring; + } + + public static class FormMapping + { + public const string View = Analysis.ViewFormMapping; + public const string Generate = Analysis.GenerateFormMapping; + } + + public static class FormWorksheet + { + public const string View = Analysis.ViewFormWorksheet; + public const string Generate = Analysis.GenerateFormWorksheet; + } + + public static class FormScoresheet + { + public const string View = Analysis.ViewFormScoresheet; + public const string Generate = Analysis.GenerateFormScoresheet; } public static class Configuration diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj index d086c0cf1d..2222c49ef6 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application.Contracts/Unity.AI.Application.Contracts.csproj @@ -6,15 +6,15 @@ Unity.AI - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs index d7fae9b818..5408e2eade 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/AIExecutionModeResolver.cs @@ -15,6 +15,9 @@ public class AIExecutionModeResolver(IConfiguration configuration) : ITransientD { public const string AttachmentSummaryOperation = AIPromptTypes.AttachmentSummary; public const string ApplicationScoringOperation = AIPromptTypes.ApplicationScoring; + public const string FormMappingOperation = AIPromptTypes.FormMapping; + public const string FormWorksheetOperation = AIPromptTypes.FormWorksheet; + public const string FormScoresheetOperation = AIPromptTypes.FormScoresheet; public AIExecutionMode ResolveMode(string operationName) { 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 eb882c8cae..26923afa80 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,19 +2,31 @@ using System; using System.Linq; using System.Threading.Tasks; +using Unity.Flex.Domain.Scoresheets; using Unity.AI.Localization; +using Unity.GrantManager.Applications; +using Unity.Modules.Shared.Correlation; using Volo.Abp; using Volo.Abp.DependencyInjection; +using Volo.Abp.Linq; namespace Unity.AI.Operations; public class AIGenerationPrerequisiteValidator( - IAIApplicationInputDataProvider dataProvider, + IApplicationRepository applicationRepository, + IApplicationFormRepository applicationFormRepository, + IApplicationFormVersionRepository applicationFormVersionRepository, + IApplicationFormSubmissionRepository applicationFormSubmissionRepository, + IApplicationChefsFileAttachmentRepository applicationChefsFileAttachmentRepository, + IScoresheetRepository scoresheetRepository, + IAsyncQueryableExecuter asyncExecuter, IStringLocalizer localizer) : IAIGenerationPrerequisiteValidator, ITransientDependency { public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) { - if (!await dataProvider.HasAttachmentsAsync(applicationId)) + var attachmentQuery = await applicationChefsFileAttachmentRepository.GetQueryableAsync(); + var hasAttachments = await asyncExecuter.AnyAsync(attachmentQuery.Where(a => a.ApplicationId == applicationId)); + if (!hasAttachments) { throw new UserFriendlyException(localizer[AILocalizationKeys.NoAttachmentsAvailable]); } @@ -22,7 +34,8 @@ public async Task EnsureAttachmentSummaryAvailableAsync(Guid applicationId) public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) { - if (!await dataProvider.HasSubmissionAsync(applicationId)) + var submission = await applicationFormSubmissionRepository.GetByApplicationAsync(applicationId); + if (submission == null || string.IsNullOrWhiteSpace(submission.Submission)) { throw new UserFriendlyException(localizer[AILocalizationKeys.ApplicationAnalysisRequiresSubmission]); } @@ -30,16 +43,44 @@ public async Task EnsureApplicationAnalysisAvailableAsync(Guid applicationId) public async Task EnsureApplicationScoringAvailableAsync(Guid applicationId) { - var applicationForm = await dataProvider.GetApplicationFormAsync(applicationId); - if (applicationForm?.ScoresheetId == null) + var application = await applicationRepository.GetAsync(applicationId); + var applicationForm = await applicationFormRepository.GetAsync(application.ApplicationFormId); + if (applicationForm.ScoresheetId == null) { throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheet]); } - var scoresheet = await dataProvider.GetScoresheetAsync(applicationForm.ScoresheetId.Value); + var scoresheet = await scoresheetRepository.GetWithChildrenAsync(applicationForm.ScoresheetId.Value); if (scoresheet == null || !scoresheet.Sections.Any() || !scoresheet.Sections.SelectMany(s => s.Fields).Any()) { throw new UserFriendlyException(localizer[AILocalizationKeys.ScoringRequiresScoresheetFields]); } } + + public async Task EnsureFormMappingAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormMappingRequiresFormVersion]); + } + } + + public async Task EnsureFormWorksheetAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormWorksheetRequiresFormVersion]); + } + } + + public async Task EnsureFormScoresheetAvailableAsync(Guid applicationFormVersionId) + { + var formVersion = await applicationFormVersionRepository.FindAsync(applicationFormVersionId); + if (formVersion == null) + { + throw new UserFriendlyException(localizer[AILocalizationKeys.FormScoresheetRequiresFormVersion]); + } + } } 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 index 5b7cfa2bae..baf2fe7990 100644 --- 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 @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Unity.AI.Operations; using Unity.GrantManager.Applications; using Volo.Abp.DependencyInjection; using Volo.Abp.Uow; 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 0f5a437289..89dacc0e07 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 @@ -22,7 +22,6 @@ public class AttachmentSummaryService( ITextExtractionService textExtractionService, IAIService aiService, IAIGenerationPrerequisiteValidator aiGenerationPrerequisiteValidator, - AIExecutionModeResolver executionModeResolver, IUnitOfWorkManager unitOfWorkManager, ILogger logger, IStringLocalizer localizer) : IAttachmentSummaryService, ITransientDependency @@ -65,124 +64,20 @@ public async Task> GenerateAndSaveAsync(IEnumerable attachmen throw new UserFriendlyException(localizer[AILocalizationKeys.SelectAttachmentForSummaries]); } - var mode = executionModeResolver.ResolveMode(AIExecutionModeResolver.AttachmentSummaryOperation); - if (mode == AIExecutionMode.Batch) - { - return await GenerateBatchAsync(ids, promptVersion, cancellationToken); - } - - if (mode != AIExecutionMode.Sequential) - { - logger.LogWarning( - "AI attachment summary {ExecutionMode} mode is not supported by the current repository-backed execution path. Falling back to sequential execution.", - mode); - mode = AIExecutionMode.Sequential; - } - return await AIExecutionStrategy.RunAsync( ids, - mode, + AIExecutionMode.Sequential, id => GenerateOrFallbackAsync(id, promptVersion, cancellationToken), - 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 + async batch => { - 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)) + var summaries = new List(batch.Count); + foreach (var attachmentId in batch) { - LogEmptyExtraction(attachmentId, fileName, attachmentStream); - failures[attachmentId] = TextExtractionFailedSummary; - continue; + summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion, cancellationToken)); } - 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, - CancellationToken cancellationToken) - { - var summaries = new List(attachmentIds.Count); - foreach (var attachmentId in attachmentIds) - { - summaries.Add(await GenerateOrFallbackAsync(attachmentId, promptVersion, cancellationToken)); - } - - return summaries; + return summaries; + }); } private async Task GenerateOrFallbackAsync( diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormMappingService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormMappingService.cs new file mode 100644 index 0000000000..b48f40b1fe --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormMappingService.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Requests; +using Unity.AI.Responses; + +namespace Unity.AI.Operations; + +public interface IFormMappingService +{ + Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormScoresheetService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormScoresheetService.cs new file mode 100644 index 0000000000..1fcb26e490 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormScoresheetService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Requests; +using Unity.AI.Responses; + +namespace Unity.AI.Operations; + +public interface IFormScoresheetService +{ + Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormWorksheetService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormWorksheetService.cs new file mode 100644 index 0000000000..75f8ec7bdf --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Operations/IFormWorksheetService.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using Unity.AI.Requests; +using Unity.AI.Responses; + +namespace Unity.AI.Operations; + +public interface IFormWorksheetService +{ + Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs index f908e1a388..081410e782 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/AIPromptTypes.cs @@ -5,4 +5,7 @@ public static class AIPromptTypes public const string AttachmentSummary = "AttachmentSummary"; public const string ApplicationAnalysis = "ApplicationAnalysis"; public const string ApplicationScoring = "ApplicationScoring"; + public const string FormMapping = "FormMapping"; + public const string FormWorksheet = "FormWorksheet"; + public const string FormScoresheet = "FormScoresheet"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/form-worksheet.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/form-worksheet.user.txt new file mode 100644 index 0000000000..1cccb19f32 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/form-worksheet.user.txt @@ -0,0 +1,37 @@ +WORKSHEET CONTEXT: +{{DATA}} + +OUTPUT +{ + "Name": "", + "Title": "", + "Version": , + "Published": true, + "Sections": [ + { + "Name": "", + "Order": 1, + "Fields": [ + { + "Name": "", + "Key": "", + "Label": "", + "Type": , + "Definition": "" + } + ] + } + ], + "ReportColumns": "", + "ReportKeys": "", + "ReportViewName": "" +} + +Rules: +- Return one worksheet definition JSON object only. +- The context includes CHEFS fields, Unity core fields, and existing worksheet-derived custom fields. +- Use the provided form context to decide which custom fields are genuinely needed. +- Prefer existing Unity core fields when they already satisfy the need. +- Only create additional worksheet custom fields when the form genuinely needs them. +- Keep the worksheet structure valid for Flex. +- Return valid plain JSON only. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt new file mode 100644 index 0000000000..3c791b606b --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.system.txt @@ -0,0 +1,4 @@ +You are a careful mapping assistant for human reviewers. +Compare CHEFS fields, Unity core fields, and worksheet fields to suggest likely mappings. +Do not invent fields, persist changes, or assume a worksheet should exist if one is not clearly justified. +Return only valid JSON in the exact format requested. diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt new file mode 100644 index 0000000000..3237f3c5ca --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Prompts/Versions/v2/onboarding-mapping.user.txt @@ -0,0 +1,26 @@ +FORM MAPPING CONTEXT: +{{DATA}} + +OUTPUT +{ + "": "", + "": "" +} + +Rules: +- Return one flat JSON object only. +- The context is grouped as `chefsData` and `unityData`. +- `chefsData.fields` contains the CHEFS source fields. +- `unityData.coreFields` contains Unity target fields. +- `unityData.customFields` contains worksheet-derived Unity target fields. +- Each property name must be a Unity core or worksheet-derived target field name from the provided context. +- Each property value must be the CHEFS source field name that best matches that Unity target field. +- Only include mappings that are clearly semantically equivalent or strongly related by label, name, type, and purpose. +- Do not force one-to-one coverage. Omit Unity fields when no CHEFS field is a sensible match. +- Omit CHEFS fields that do not clearly map to a Unity target field. +- Do not map platform/system identifiers such as SubmissionId, SubmissionDate, or ConfirmationId; they are managed by Unity and should be omitted if present. +- If no fields clearly match, return `{}`. +- The mapping is dynamic; do not hardcode or assume a fixed list of fields. +- Prefer existing Unity core intake fields when they already fit the source field. +- Only use worksheet custom field targets when the form genuinely needs them. +- Return valid plain JSON only. 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 e915cad4e8..67e248c811 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 @@ -40,9 +40,12 @@ public static string BuildAttachmentSummaryUserPrompt( }); } - public static string BuildAttachmentSummaryBatchUserPrompt( + public static string BuildApplicationScoringUserPrompt( string userPromptTemplate, + string data, string attachments, + string section, + string response, string? metadataJson = null) { return RenderPromptTemplate( @@ -50,16 +53,16 @@ public static string BuildAttachmentSummaryBatchUserPrompt( metadataJson, new Dictionary { - ["ATTACHMENTS"] = attachments + ["DATA"] = data, + ["ATTACHMENTS"] = attachments, + ["SECTION"] = section, + ["RESPONSE"] = response }); } - public static string BuildApplicationScoringUserPrompt( + public static string BuildFormMappingUserPrompt( string userPromptTemplate, string data, - string attachments, - string section, - string response, string? metadataJson = null) { return RenderPromptTemplate( @@ -67,10 +70,7 @@ public static string BuildApplicationScoringUserPrompt( metadataJson, new Dictionary { - ["DATA"] = data, - ["ATTACHMENTS"] = attachments, - ["SECTION"] = section, - ["RESPONSE"] = response + ["DATA"] = data }); } 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 115cc229fe..7f78c2879f 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,6 +1,4 @@ using System; -using System.Text.Json; -using Unity.AI.Prompts; namespace Unity.AI.Runtime; @@ -10,22 +8,4 @@ public sealed record AIPromptTemplateSnapshot( 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/AIProviderPayloadValidator.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AI/Runtime/AIProviderPayloadValidator.cs index e481ee5823..23d9ed10da 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,39 +14,6 @@ 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)) @@ -54,48 +21,48 @@ public static AIResponseValidationResult ValidateApplicationAnalysisJson(string return AIResponseValidationResult.Invalid("Application analysis response was not valid JSON."); } - if (!root.TryGetProperty(AIJsonKeys.Decision, out var decision) || decision.ValueKind != JsonValueKind.String) + if (!root.TryGetProperty("decision", out var decision) || decision.ValueKind != JsonValueKind.String) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Decision}' (expected string)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field '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'."); + "Application analysis response has invalid 'decision' value. Expected 'PROCEED' or 'HOLD'."); } - if (!root.TryGetProperty(AIJsonKeys.Errors, out var errors) || errors.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("errors", out var errors) || errors.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Errors}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'errors' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Warnings, out var warnings) || warnings.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("warnings", out var warnings) || warnings.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Warnings}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'warnings' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Summaries, out var summaries) || summaries.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("summaries", out var summaries) || summaries.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Summaries}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'summaries' (expected array)."); } - if (!root.TryGetProperty(AIJsonKeys.Recommendations, out var recommendations) || recommendations.ValueKind != JsonValueKind.Array) + if (!root.TryGetProperty("recommendations", out var recommendations) || recommendations.ValueKind != JsonValueKind.Array) { - return AIResponseValidationResult.Invalid($"Application analysis response is missing or invalid required field '{AIJsonKeys.Recommendations}' (expected array)."); + return AIResponseValidationResult.Invalid("Application analysis response is missing or invalid required field 'recommendations' (expected array)."); } if (summaries.GetArrayLength() == 0) { return AIResponseValidationResult.Invalid( - $"Application analysis response must include at least one item in '{AIJsonKeys.Summaries}'."); + "Application analysis response must include at least one item in 'summaries'."); } if (recommendations.GetArrayLength() == 0) { return AIResponseValidationResult.Invalid( - $"Application analysis response must include at least one item in '{AIJsonKeys.Recommendations}'."); + "Application analysis response must include at least one item in 'recommendations'."); } return AIResponseValidationResult.Success(); @@ -122,7 +89,7 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r $"Application scoring response is missing required answer object for question id '{questionId}'."); } - if (!answerObject.TryGetProperty(AIJsonKeys.Answer, out var answerValue) + if (!answerObject.TryGetProperty("answer", out var answerValue) || answerValue.ValueKind == JsonValueKind.Null || answerValue.ValueKind == JsonValueKind.Object || answerValue.ValueKind == JsonValueKind.Array) @@ -131,7 +98,7 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r $"Application scoring response is missing a valid answer for question id '{questionId}'."); } - if (!answerObject.TryGetProperty(AIJsonKeys.Confidence, out var confidenceValue) + if (!answerObject.TryGetProperty("confidence", out var confidenceValue) || confidenceValue.ValueKind != JsonValueKind.Number || !confidenceValue.TryGetDecimal(out var confidence) || confidence < 0m @@ -145,6 +112,252 @@ public static AIResponseValidationResult ValidateApplicationScoringJson(string r return AIResponseValidationResult.Success(); } + public static AIResponseValidationResult ValidateFormMappingJson(string response) + { + if (!TryParseRootObject(response, out _)) + { + return AIResponseValidationResult.Invalid("Mapping suggestion response was not valid JSON."); + } + + return AIResponseValidationResult.Success(); + } + + public static AIResponseValidationResult ValidateFormWorksheetJson(string response) + { + if (!TryParseRootObject(response, out var root)) + { + return AIResponseValidationResult.Invalid("Form worksheet response was not valid JSON."); + } + + if (!root.TryGetProperty("fields", out var fields) + || fields.ValueKind != JsonValueKind.Array) + { + return AIResponseValidationResult.Invalid("Form worksheet response must include a 'fields' array."); + } + + return AIResponseValidationResult.Success(); + } + + public static AIResponseValidationResult ValidateFormScoresheetJson(string response) + { + if (!TryParseRootObject(response, out var root)) + { + return AIResponseValidationResult.Invalid("Scoresheet response was not valid JSON."); + } + + foreach (var propertyName in new[] { "Title", "Name" }) + { + var result = ValidateRequiredStringProperty(root, propertyName, "scoresheet"); + if (!result.IsValid) + { + return result; + } + } + + foreach (var propertyName in new[] { "ReportColumns", "ReportKeys", "ReportViewName" }) + { + var result = ValidateRequiredStringProperty(root, propertyName, "scoresheet", allowEmpty: true); + if (!result.IsValid) + { + return result; + } + } + + foreach (var propertyName in new[] { "Version", "Order" }) + { + var result = ValidateRequiredUIntProperty(root, propertyName, "scoresheet"); + if (!result.IsValid) + { + return result; + } + } + + var publishedResult = ValidateRequiredBooleanProperty(root, "Published", "scoresheet"); + return !publishedResult.IsValid + ? publishedResult + : ValidateSections(root, "scoresheet"); + } + + private static AIResponseValidationResult ValidateSections(JsonElement root, string responseName) + { + if (!TryGetProperty(root, "Sections", out var sections) || sections.ValueKind != JsonValueKind.Array) + { + return AIResponseValidationResult.Invalid($"{responseName} response is missing or invalid required field 'Sections' (expected array)."); + } + + if (sections.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid($"{responseName} response must include at least one section."); + } + + var sectionNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var fieldNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var section in sections.EnumerateArray()) + { + if (section.ValueKind != JsonValueKind.Object) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains an invalid section (expected object)."); + } + + var sectionNameResult = ValidateRequiredStringProperty(section, "Name", $"{responseName} section"); + if (!sectionNameResult.IsValid) + { + return sectionNameResult; + } + + if (!sectionNames.Add(section.GetProperty("Name").GetString()!)) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains duplicate section names."); + } + + var sectionOrderResult = ValidateRequiredUIntProperty(section, "Order", $"{responseName} section"); + if (!sectionOrderResult.IsValid) + { + return sectionOrderResult; + } + + if (!TryGetProperty(section, "Fields", out var fields) || fields.ValueKind != JsonValueKind.Array) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains a section without valid 'Fields' (expected array)."); + } + + if (fields.GetArrayLength() == 0) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains a section without fields."); + } + + foreach (var field in fields.EnumerateArray()) + { + var result = ValidateField(field, responseName); + if (!result.IsValid) + { + return result; + } + + if (!fieldNames.Add(field.GetProperty("Name").GetString()!)) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains duplicate field names."); + } + } + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateField(JsonElement field, string responseName) + { + if (field.ValueKind != JsonValueKind.Object) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains an invalid field (expected object)."); + } + + foreach (var propertyName in new[] { "Name", "Label" }) + { + var result = ValidateRequiredStringProperty(field, propertyName, $"{responseName} field"); + if (!result.IsValid) + { + return result; + } + } + + foreach (var propertyName in new[] { "Order", "Type" }) + { + var result = ValidateRequiredUIntProperty(field, propertyName, $"{responseName} field"); + if (!result.IsValid) + { + return result; + } + } + + var definitionResult = ValidateDefinitionProperty(field, responseName); + if (!definitionResult.IsValid) + { + return definitionResult; + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateDefinitionProperty(JsonElement field, string responseName) + { + if (!TryGetProperty(field, "Definition", out var definition) || definition.ValueKind == JsonValueKind.Null) + { + return AIResponseValidationResult.Invalid($"{responseName} response contains a field without a Definition."); + } + + if (definition.ValueKind != JsonValueKind.String) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition must be a JSON object encoded as a string."); + } + + var definitionText = definition.GetString(); + if (string.IsNullOrWhiteSpace(definitionText)) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition cannot be empty."); + } + + try + { + using var definitionDocument = JsonDocument.Parse(definitionText); + if (definitionDocument.RootElement.ValueKind != JsonValueKind.Object) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition must contain a JSON object."); + } + } + catch (JsonException) + { + return AIResponseValidationResult.Invalid($"{responseName} response field Definition must contain valid JSON."); + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateRequiredStringProperty(JsonElement element, string propertyName, string sourceName, bool allowEmpty = false) + { + if (!TryGetProperty(element, propertyName, out var property) + || property.ValueKind != JsonValueKind.String + || (!allowEmpty && string.IsNullOrWhiteSpace(property.GetString()))) + { + var expectation = allowEmpty ? "string" : "non-empty string"; + return AIResponseValidationResult.Invalid($"{sourceName} response is missing or invalid required field '{propertyName}' (expected {expectation})."); + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateRequiredUIntProperty(JsonElement element, string propertyName, string sourceName) + { + if (!TryGetProperty(element, propertyName, out var property) + || property.ValueKind != JsonValueKind.Number + || !property.TryGetUInt32(out _)) + { + return AIResponseValidationResult.Invalid($"{sourceName} response is missing or invalid required field '{propertyName}' (expected non-negative integer)."); + } + + return AIResponseValidationResult.Success(); + } + + private static AIResponseValidationResult ValidateRequiredBooleanProperty(JsonElement element, string propertyName, string sourceName) + { + if (!TryGetProperty(element, propertyName, out var property) || property.ValueKind is not JsonValueKind.True and not JsonValueKind.False) + { + return AIResponseValidationResult.Invalid($"{sourceName} response is missing or invalid required field '{propertyName}' (expected boolean)."); + } + + return AIResponseValidationResult.Success(); + } + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement property) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out property)) + { + return true; + } + + property = default; + return false; + } private static HashSet ExtractQuestionIds(string sectionJson) { var ids = new HashSet(StringComparer.OrdinalIgnoreCase); 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 7490142fbe..cdb7573430 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,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Memory; using System; using System.Linq; using System.Text.Json; @@ -17,9 +18,11 @@ public class OpenAIConfigurationResolver( IRepository modelRepository, IRepository operationRepository, IRepository promptRepository, + IMemoryCache memoryCache, IConfiguration configuration, IDataFilter multiTenantDataFilter) : ITransientDependency { + private static readonly TimeSpan SettingsCacheDuration = TimeSpan.FromMinutes(5); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true @@ -28,6 +31,7 @@ public class OpenAIConfigurationResolver( private readonly IRepository _modelRepository = modelRepository; private readonly IRepository _operationRepository = operationRepository; private readonly IRepository _promptRepository = promptRepository; + private readonly IMemoryCache _memoryCache = memoryCache; private readonly IConfiguration _configuration = configuration; private readonly IDataFilter _multiTenantDataFilter = multiTenantDataFilter; @@ -43,6 +47,12 @@ public async Task ResolveOperationSettingsAsync( string operationName, CancellationToken cancellationToken = default) { + var cacheKey = BuildOperationSettingsCacheKey(operationName); + if (_memoryCache.TryGetValue(cacheKey, out OpenAIOperationSettings? cachedSettings) && cachedSettings is not null) + { + return cachedSettings; + } + var operation = await ResolveOperationAsync(operationName, cancellationToken); if (operation == null) { @@ -74,7 +84,7 @@ public async Task ResolveOperationSettingsAsync( } var apiKey = Required($"Azure:{providerName}:ApiKey"); - return new OpenAIOperationSettings( + var settings = new OpenAIOperationSettings( providerName, model.Name, apiKey, @@ -84,6 +94,9 @@ public async Task ResolveOperationSettingsAsync( modelSettings.Temperature, operation.CompletionTokens, $"v{prompt.VersionNumber}"); + + _memoryCache.Set(cacheKey, settings, SettingsCacheDuration); + return settings; } public async Task ResolveConfiguredTemperatureAsync(string? modelName = null, CancellationToken cancellationToken = default) @@ -202,16 +215,6 @@ public async Task ResolveProfileNameAsync(string? modelName = null, Canc return (model, settings); } - private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) - { - var operations = await _operationRepository.GetListAsync( - operation => operation.IsActive, - cancellationToken: cancellationToken); - - return operations.FirstOrDefault(operation => - string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); - } - private static AIModelSettings ResolveModelSettings(AIModel model) { var settings = JsonSerializer.Deserialize(model.SettingsJson, JsonOptions); @@ -279,6 +282,11 @@ private async Task ResolvePromptVersionAsyncCore(string operationName, C return $"v{prompt.VersionNumber}"; } + private static string BuildOperationSettingsCacheKey(string operationName) + { + return $"ai:operation-settings:{operationName.Trim().ToLowerInvariant()}"; + } + private async Task LoadPromptAsync(Guid promptId, CancellationToken cancellationToken) { using (_multiTenantDataFilter.Disable()) @@ -286,4 +294,49 @@ private async Task LoadPromptAsync(Guid promptId, CancellationToken ca return await _promptRepository.GetAsync(promptId, cancellationToken: cancellationToken); } } + + private async Task ResolveOperationAsync(string operationName, CancellationToken cancellationToken) + { + var cacheKey = BuildOperationSnapshotCacheKey(operationName); + if (_memoryCache.TryGetValue(cacheKey, out ResolvedOperationSnapshot? cachedOperation) && cachedOperation is not null) + { + return cachedOperation; + } + + var operations = await _operationRepository.GetListAsync( + operation => operation.IsActive, + cancellationToken: cancellationToken); + + var operation = operations.FirstOrDefault(operation => + string.Equals(operation.Name, operationName, StringComparison.OrdinalIgnoreCase)); + + if (operation == null) + { + return null; + } + + var snapshot = new ResolvedOperationSnapshot( + operation.Id, + operation.Name, + operation.AIModelId, + operation.AIPromptId, + operation.CompletionTokens, + operation.IsActive); + + _memoryCache.Set(cacheKey, snapshot, SettingsCacheDuration); + return snapshot; + } + + private static string BuildOperationSnapshotCacheKey(string operationName) + { + return $"ai:operation-snapshot:{operationName.Trim().ToLowerInvariant()}"; + } + + private sealed record ResolvedOperationSnapshot( + Guid Id, + string Name, + Guid AIModelId, + Guid AIPromptId, + int CompletionTokens, + bool IsActive); } 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 05c1ebb027..1cb90c3455 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 @@ -19,27 +19,27 @@ public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(strin return response; } - if (TryGetStringProperty(root, AIJsonKeys.Decision, out var decision)) + if (TryGetStringProperty(root, "decision", out var decision)) { response.Decision = decision.Trim().ToUpperInvariant(); } - if (TryGetArrayProperty(root, AIJsonKeys.Errors, out var errorsArray)) + if (TryGetArrayProperty(root, "errors", out var errorsArray)) { response.Errors = ParseFindings(errorsArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Warnings, out var warningsArray)) + if (TryGetArrayProperty(root, "warnings", out var warningsArray)) { response.Warnings = ParseFindings(warningsArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Summaries, out var summariesArray)) + if (TryGetArrayProperty(root, "summaries", out var summariesArray)) { response.Summaries = ParseFindings(summariesArray).ToList(); } - if (TryGetArrayProperty(root, AIJsonKeys.Recommendations, out var recommendationsArray)) + if (TryGetArrayProperty(root, "recommendations", out var recommendationsArray)) { response.Recommendations = ParseFindings(recommendationsArray).ToList(); } @@ -47,66 +47,6 @@ public static ApplicationAnalysisResponse ParseApplicationAnalysisResponse(strin return response; } - private static string AddIdsToAnalysisItems(string analysisJson) - { - try - { - using var jsonDoc = JsonDocument.Parse(analysisJson); - using var memoryStream = new System.IO.MemoryStream(); - using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - - foreach (var property in jsonDoc.RootElement.EnumerateObject()) - { - var outputPropertyName = property.Name; - - if (outputPropertyName == AIJsonKeys.Errors || - outputPropertyName == AIJsonKeys.Warnings || - outputPropertyName == AIJsonKeys.Summaries || - outputPropertyName == AIJsonKeys.Recommendations) - { - writer.WritePropertyName(outputPropertyName); - writer.WriteStartArray(); - - foreach (var item in property.Value.EnumerateArray()) - { - writer.WriteStartObject(); - - foreach (var itemProperty in item.EnumerateObject()) - { - itemProperty.WriteTo(writer); - } - - if (!item.TryGetProperty(AIJsonKeys.Id, out var idProp) || - idProp.ValueKind != JsonValueKind.String || - string.IsNullOrWhiteSpace(idProp.GetString())) - { - writer.WriteString(AIJsonKeys.Id, Guid.NewGuid().ToString()); - } - - writer.WriteEndObject(); - } - - writer.WriteEndArray(); - continue; - } - - property.WriteTo(writer); - } - - writer.WriteEndObject(); - writer.Flush(); - } - - return Encoding.UTF8.GetString(memoryStream.ToArray()); - } - catch - { - return analysisJson; - } - } - public static ApplicationScoringResponse ParseApplicationScoringResponse(string raw, IReadOnlyDictionary? questionIdAliasMap = null) { var response = new ApplicationScoringResponse(); @@ -151,47 +91,77 @@ public static ApplicationScoringResponse ParseApplicationScoringResponse(string return response; } - public static AttachmentSummaryBatchResponse ParseAttachmentSummaryBatchResponse(string raw) + public static FormMappingResponse ParseFormMappingResponse(string raw) { - var response = new AttachmentSummaryBatchResponse(); if (!TryParseJsonObjectFromResponse(raw, out var root)) { - return response; + return new FormMappingResponse(); } - if (!root.TryGetProperty("attachments", out var attachments) || attachments.ValueKind != JsonValueKind.Array) + return new FormMappingResponse { - return response; - } + Mapping = root.GetRawText() + }; + } - foreach (var attachment in attachments.EnumerateArray()) + private static string AddIdsToAnalysisItems(string analysisJson) + { + try { - if (attachment.ValueKind != JsonValueKind.Object) + using var jsonDoc = JsonDocument.Parse(analysisJson); + using var memoryStream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(memoryStream, new JsonWriterOptions { Indented = true })) { - continue; - } + writer.WriteStartObject(); - var attachmentId = attachment.TryGetProperty("attachmentId", out var idProp) && idProp.ValueKind == JsonValueKind.String - ? idProp.GetString() ?? string.Empty - : string.Empty; + foreach (var property in jsonDoc.RootElement.EnumerateObject()) + { + var outputPropertyName = property.Name; - if (string.IsNullOrWhiteSpace(attachmentId)) - { - continue; - } + if (outputPropertyName == "errors" || + outputPropertyName == "warnings" || + outputPropertyName == "summaries" || + outputPropertyName == "recommendations") + { + writer.WritePropertyName(outputPropertyName); + writer.WriteStartArray(); - var summary = attachment.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String - ? summaryProp.GetString() ?? string.Empty - : string.Empty; + foreach (var item in property.Value.EnumerateArray()) + { + writer.WriteStartObject(); - response.Attachments.Add(new AttachmentSummaryBatchItemResponse - { - AttachmentId = attachmentId, - Summary = summary - }); - } + foreach (var itemProperty in item.EnumerateObject()) + { + itemProperty.WriteTo(writer); + } - return response; + if (!item.TryGetProperty("id", out var idProp) || + idProp.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(idProp.GetString())) + { + writer.WriteString("id", Guid.NewGuid().ToString()); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + continue; + } + + property.WriteTo(writer); + } + + writer.WriteEndObject(); + writer.Flush(); + } + + return Encoding.UTF8.GetString(memoryStream.ToArray()); + } + catch + { + return analysisJson; + } } private static IEnumerable ParseFindings(JsonElement findingsArray) @@ -204,23 +174,23 @@ private static IEnumerable ParseFindings(JsonElement } var id = Guid.NewGuid().ToString(); - if (item.TryGetProperty(AIJsonKeys.Id, out var idProp) && idProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) { id = idProp.GetString() ?? id; } - var dismissed = item.TryGetProperty(AIJsonKeys.Dismissed, out var dismissedProp) && + var dismissed = item.TryGetProperty("dismissed", out var dismissedProp) && (dismissedProp.ValueKind == JsonValueKind.True || dismissedProp.ValueKind == JsonValueKind.False) && dismissedProp.GetBoolean(); string? title = null; - if (item.TryGetProperty(AIJsonKeys.Title, out var titleProp) && titleProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("title", out var titleProp) && titleProp.ValueKind == JsonValueKind.String) { title = titleProp.GetString(); } string? detail = null; - if (item.TryGetProperty(AIJsonKeys.Detail, out var detailProp) && detailProp.ValueKind == JsonValueKind.String) + if (item.TryGetProperty("detail", out var detailProp) && detailProp.ValueKind == JsonValueKind.String) { detail = detailProp.GetString(); } 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 3cafa504d9..9da405ab77 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 @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Unity.AI.Models; +using Unity.AI.Operations; using Unity.AI.Prompts; using Unity.AI.Requests; using Unity.AI.Responses; @@ -14,8 +15,8 @@ namespace Unity.AI.Runtime { - [ExposeServices(typeof(IAIService))] - public class OpenAIRuntimeService : IAIService, ITransientDependency + [ExposeServices(typeof(IAIService), typeof(IFormMappingService), typeof(IFormWorksheetService), typeof(IFormScoresheetService))] + public class OpenAIRuntimeService : IAIService, IFormMappingService, IFormWorksheetService, IFormScoresheetService, ITransientDependency { private readonly ILogger _logger; private readonly OpenAITransportService _openAITransportService; @@ -25,6 +26,9 @@ public class OpenAIRuntimeService : IAIService, ITransientDependency private const string ApplicationAnalysisPromptType = AIPromptTypes.ApplicationAnalysis; private const string AttachmentSummaryPromptType = AIPromptTypes.AttachmentSummary; private const string ApplicationScoringPromptType = AIPromptTypes.ApplicationScoring; + private const string FormMappingPromptType = AIPromptTypes.FormMapping; + private const string FormWorksheetPromptType = AIPromptTypes.FormWorksheet; + private const string FormScoresheetPromptType = AIPromptTypes.FormScoresheet; private const int MaxAiAttempts = 3; public OpenAIRuntimeService( @@ -155,7 +159,7 @@ public async Task GenerateAttachmentSummaryAsync(Atta } }; var attachments = JsonSerializer.Serialize(attachmentPayload, AIJsonDefaults.Indented); - var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( + var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryUserPrompt( promptTemplate.UserPrompt, attachments, promptTemplate.MetadataJson); @@ -200,56 +204,65 @@ public async Task GenerateAttachmentSummaryAsync(Atta } } - public async Task GenerateAttachmentSummaryBatchAsync(AttachmentSummaryBatchRequest request, CancellationToken cancellationToken = default) + public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest 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 settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationScoringPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( - AttachmentSummaryPromptType, + ApplicationScoringPromptType, request.PromptVersion ?? settings.PromptVersion, cancellationToken); var promptVersion = promptTemplate.PromptVersion; + var dataJson = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented); + var sectionJson = JsonSerializer.Serialize(request.SectionSchema, AIJsonDefaults.Indented); - var attachmentsPayload = request.Attachments.Select(attachment => new + var attachmentSummaries = request.Attachments + .Select(a => $"{a.Name}: {a.Summary}") + .ToList(); + var attachments = attachmentSummaries.Count > 0 + ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) + : "[]"; + + var section = OpenAIPromptRenderer.BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); + var response = OpenAIPromptRenderer.BuildApplicationScoringResponseTemplate(section); + if (response == "{}") { - 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 - }); + _logger.LogWarning( + "Skipping AI application scoring for section {SectionName} because response template could not be built from section schema.", + request.SectionName); + return new ApplicationScoringResponse(); + } - var attachments = JsonSerializer.Serialize(attachmentsPayload, AIJsonDefaults.Indented); - var contentToAnalyze = AIPromptTemplateRenderer.BuildAttachmentSummaryBatchUserPrompt( + var applicationScoringContent = AIPromptTemplateRenderer.BuildApplicationScoringUserPrompt( promptTemplate.UserPrompt, + dataJson, attachments, + section, + response, promptTemplate.MetadataJson); + var systemPrompt = promptTemplate.SystemPrompt; - await _promptFileLogger.LogPromptInputAsync(AttachmentSummaryPromptType, promptVersion, promptTemplate.SystemPrompt, contentToAnalyze, cancellationToken); + await _promptFileLogger.LogPromptInputAsync(ApplicationScoringPromptType, promptVersion, systemPrompt, applicationScoringContent, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( - contentToAnalyze, - promptTemplate.SystemPrompt, + applicationScoringContent, + systemPrompt, settings, settings.CompletionTokens, cancellationToken: cancellationToken), - AIProviderPayloadValidator.ValidateAttachmentSummaryBatchJson, - "attachment summary batch", + content => AIProviderPayloadValidator.ValidateApplicationScoringJson(content, section), + $"application scoring section {request.SectionName}", cancellationToken); - await _promptFileLogger.LogPromptOutputAsync(AttachmentSummaryPromptType, promptVersion, result.CaptureOutput, cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { - return new AttachmentSummaryBatchResponse(); + return new ApplicationScoringResponse(); } - return OpenAIResponseParser.ParseAttachmentSummaryBatchResponse(result.Content); + return OpenAIResponseParser.ParseApplicationScoringResponse(result.Content, questionIdAliasMap); } catch (OperationCanceledException) { @@ -257,70 +270,149 @@ public async Task GenerateAttachmentSummaryBatch } catch (Exception ex) { - _logger.LogError(ex, "Attachment summary batch generation failed."); - return new AttachmentSummaryBatchResponse(); + _logger.LogError(ex, "Application scoring generation failed for section {SectionName}.", request.SectionName); + return new ApplicationScoringResponse(); } } - public async Task GenerateApplicationScoringAsync(ApplicationScoringRequest request, CancellationToken cancellationToken = default) + public async Task GenerateFormWorksheetAsync(FormWorksheetRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); try { - var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(ApplicationScoringPromptType, cancellationToken); + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(FormWorksheetPromptType, cancellationToken); var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( - ApplicationScoringPromptType, + FormWorksheetPromptType, request.PromptVersion ?? settings.PromptVersion, cancellationToken); var promptVersion = promptTemplate.PromptVersion; - var dataJson = JsonSerializer.Serialize(request.Data, AIJsonDefaults.Indented); - var sectionJson = JsonSerializer.Serialize(request.SectionSchema, AIJsonDefaults.Indented); + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( + promptTemplate.UserPrompt, + dataJson, + promptTemplate.MetadataJson); - var attachmentSummaries = request.Attachments - .Select(a => $"{a.Name}: {a.Summary}") - .ToList(); - var attachments = attachmentSummaries.Count > 0 - ? string.Join("\n- ", attachmentSummaries.Select((summary, index) => $"Attachment {index + 1}: {summary}")) - : "[]"; + await _promptFileLogger.LogPromptInputAsync(FormWorksheetPromptType, promptVersion, systemPrompt, content, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + content, + systemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateFormWorksheetJson, + "form worksheet", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(FormWorksheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); - var section = OpenAIPromptRenderer.BuildAliasedApplicationScoringSection(request.SectionName, sectionJson, out var questionIdAliasMap); - var response = OpenAIPromptRenderer.BuildApplicationScoringResponseTemplate(section); - if (response == "{}") + return new FormWorksheetResponse { - _logger.LogWarning( - "Skipping AI application scoring for section {SectionName} because response template could not be built from section schema.", - request.SectionName); - return new ApplicationScoringResponse(); - } + Worksheet = result.Outcome == AIOperationOutcome.Success + ? AIResponseJson.CleanJsonResponse(result.Content) + : "{}" + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Form worksheet generation failed."); + return new FormWorksheetResponse(); + } + } - var applicationScoringContent = AIPromptTemplateRenderer.BuildApplicationScoringUserPrompt( + public async Task GenerateFormScoresheetAsync(FormScoresheetRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(FormScoresheetPromptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + FormScoresheetPromptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + var dataJson = request.Data.GetRawText(); + var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( promptTemplate.UserPrompt, dataJson, - attachments, - section, - response, promptTemplate.MetadataJson); + + await _promptFileLogger.LogPromptInputAsync(FormScoresheetPromptType, promptVersion, systemPrompt, content, cancellationToken); + var result = await GenerateWithRetryAsync( + () => _openAITransportService.GenerateSummaryAsync( + content, + systemPrompt, + settings, + settings.CompletionTokens, + cancellationToken: cancellationToken), + AIProviderPayloadValidator.ValidateFormScoresheetJson, + "form scoresheet", + cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(FormScoresheetPromptType, promptVersion, result.CaptureOutput, cancellationToken); + + return new FormScoresheetResponse + { + Scoresheet = result.Outcome == AIOperationOutcome.Success + ? AIResponseJson.CleanJsonResponse(result.Content) + : "{}" + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Form scoresheet generation failed."); + return new FormScoresheetResponse(); + } + } + + private async Task GenerateFormMappingCoreAsync(FormMappingRequest request, string promptType, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + try + { + var settings = await _openAIConfigurationResolver.ResolveOperationSettingsAsync(promptType, cancellationToken); + var promptTemplate = await _promptTemplateProvider.GetRequiredPromptAsync( + promptType, + request.PromptVersion ?? settings.PromptVersion, + cancellationToken); + var promptVersion = promptTemplate.PromptVersion; + var dataJson = request.Data.GetRawText(); var systemPrompt = promptTemplate.SystemPrompt; + var content = AIPromptTemplateRenderer.BuildFormMappingUserPrompt( + promptTemplate.UserPrompt, + dataJson, + promptTemplate.MetadataJson); - await _promptFileLogger.LogPromptInputAsync(ApplicationScoringPromptType, promptVersion, systemPrompt, applicationScoringContent, cancellationToken); + await _promptFileLogger.LogPromptInputAsync(promptType, promptVersion, systemPrompt, content, cancellationToken); var result = await GenerateWithRetryAsync( () => _openAITransportService.GenerateSummaryAsync( - applicationScoringContent, + content, systemPrompt, settings, settings.CompletionTokens, cancellationToken: cancellationToken), - content => AIProviderPayloadValidator.ValidateApplicationScoringJson(content, section), - $"application scoring section {request.SectionName}", + AIProviderPayloadValidator.ValidateFormMappingJson, + "mapping suggestion", cancellationToken); - await _promptFileLogger.LogPromptOutputAsync(ApplicationScoringPromptType, promptVersion, result.CaptureOutput, cancellationToken); + await _promptFileLogger.LogPromptOutputAsync(promptType, promptVersion, result.CaptureOutput, cancellationToken); if (result.Outcome != AIOperationOutcome.Success) { - return new ApplicationScoringResponse(); + return new FormMappingResponse(); } - return OpenAIResponseParser.ParseApplicationScoringResponse(result.Content, questionIdAliasMap); + return new FormMappingResponse + { + Mapping = AIResponseJson.CleanJsonResponse(result.Content) + }; } catch (OperationCanceledException) { @@ -328,11 +420,14 @@ public async Task GenerateApplicationScoringAsync(Ap } catch (Exception ex) { - _logger.LogError(ex, "Application scoring generation failed for section {SectionName}.", request.SectionName); - return new ApplicationScoringResponse(); + _logger.LogError(ex, "Mapping suggestion generation failed."); + return new FormMappingResponse(); } } + public Task GenerateFormMappingAsync(FormMappingRequest request, CancellationToken cancellationToken = default) => + GenerateFormMappingCoreAsync(request, FormMappingPromptType, cancellationToken); + private async Task GenerateWithRetryAsync( Func> operation, Func validator, @@ -461,7 +556,7 @@ private static string ExtractSummaryFromJson(string output) return output?.Trim() ?? string.Empty; } - if (jsonObject.TryGetProperty(AIJsonKeys.Summary, out var summaryProp) && + if (jsonObject.TryGetProperty("summary", out var summaryProp) && summaryProp.ValueKind == JsonValueKind.String) { return summaryProp.GetString() ?? string.Empty; 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 769a538af1..89d503f1b1 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 @@ -51,17 +51,18 @@ public async Task GenerateSummaryAsync( var completion = result.Value; var rawResponse = result.GetRawResponse(); - var responseContent = rawResponse.Content.ToString(); + var statusCode = rawResponse?.Status; + var responseContent = rawResponse?.Content?.ToString() ?? string.Empty; var modelOutput = ExtractModelOutput(completion, responseContent); var providerResponse = BuildProviderResponseFromMetadata( modelOutput ?? string.Empty, responseContent, TryExtractProviderMetadata(responseContent), - rawResponse.Status); + statusCode); if (string.IsNullOrWhiteSpace(modelOutput)) { - LogEmptyModelOutput(completion, providerResponse, rawResponse.Status); + LogEmptyModelOutput(completion, providerResponse, statusCode ?? 0); } return string.IsNullOrWhiteSpace(modelOutput) diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs index 6030c31218..1c1ed15bd7 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/AIApplicationModule.cs @@ -31,6 +31,8 @@ public override void PreConfigureServices(ServiceConfigurationContext context) public override void ConfigureServices(ServiceConfigurationContext context) { + context.Services.AddMemoryCache(); + Configure(options => { options.IsEnabled = true; @@ -55,4 +57,4 @@ public override void ConfigureServices(ServiceConfigurationContext context) context.Services.AddAssemblyOf(); } -} \ No newline at end of file +} 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 index bf8357a391..1f0d8f4387 100644 --- 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 @@ -3,8 +3,8 @@ using System.Linq; using Microsoft.Extensions.Logging; using System.Threading.Tasks; -using Unity.AI.Domain; using Unity.AI.Operations; +using Unity.AI.Domain; using Unity.AI.Prompts; using Unity.GrantManager.GrantApplications; using Volo.Abp.Data; @@ -27,7 +27,10 @@ public class AIOperationDataSeeder( [ new(AIPromptTypes.ApplicationAnalysis, AIPromptTypes.ApplicationAnalysis, 1, 4000), new(AIPromptTypes.AttachmentSummary, AIPromptTypes.AttachmentSummary, 1, 2000), - new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000) + new(AIPromptTypes.ApplicationScoring, AIPromptTypes.ApplicationScoring, 1, 8000), + new(AIPromptTypes.FormMapping, AIPromptTypes.FormMapping, 2, 2000), + new(AIPromptTypes.FormWorksheet, AIPromptTypes.FormWorksheet, 2, 4000), + new(AIPromptTypes.FormScoresheet, AIPromptTypes.FormScoresheet, 2, 4000) ]; public async Task SeedAsync(DataSeedContext context) 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 64041c1d0b..7df54a2a2a 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 @@ -28,6 +28,9 @@ public async Task SeedAsync(DataSeedContext context) await SeedAnalysisPromptAsync(); await SeedAttachmentPromptAsync(); await SeedScoresheetPromptAsync(); + await SeedFormMappingPromptAsync(); + await SeedFormWorksheetPromptAsync(); + await SeedFormScoresheetPromptAsync(); } } @@ -110,6 +113,23 @@ await EnsurePromptAsync( commonRules: CommonRules)); } + // ─── MAPPING SUGGESTION ───────────────────────────────────────────────── + + private async Task SeedFormMappingPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormMapping, 2, FormMappingSystemV2, FormMappingUserV2, FormMappingMetadataV2); + } + + private async Task SeedFormWorksheetPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormWorksheet, 2, FormWorksheetSystemV2, FormWorksheetUserV2, FormWorksheetMetadataV2); + } + + private async Task SeedFormScoresheetPromptAsync() + { + await EnsurePromptAsync(AIPromptTypes.FormScoresheet, 2, FormScoresheetSystemV2, FormScoresheetUserV2, FormScoresheetMetadataV2); + } + // ─── HELPERS ────────────────────────────────────────────────────────────── private static string BuildSections( @@ -780,6 +800,153 @@ 4. Choose the most conservative valid answer supported by that evidence. - The "answer" value type must match question type: Number => numeric; YesNo/SelectList/Text/TextArea => string. """; + // ── v0/mapping-suggestion.system.txt ──────────────────────────────────── + private const string FormMappingSystemV2 = """ + You are a careful mapping assistant for human reviewers. + Return structured JSON for recommended Unity-to-CHEFS field mapping. + Do not invent fields, persist changes, or add wrapper sections. + Return only valid JSON in the exact mapping shape requested. + """; + + // ── v2/onboarding-mapping.user.txt ───────────────────────────────────── + private const string FormMappingUserV2 = """ + FORM MAPPING CONTEXT: + {{DATA}} + + OUTPUT + { + "": "", + "": "" + } + + Important: + - Use only FORM MAPPING CONTEXT as evidence. + - The context is grouped as chefsData and unityData. + - chefsData.fields contains the CHEFS source fields. + - unityData.coreFields contains Unity target fields. + - unityData.customFields contains worksheet-derived Unity target fields. + - existingMapping contains the current Unity-to-CHEFS assignments, when any exist. + - Return a complete mapping, including existing mappings and any new suggestions. + - Preserve every existing non-empty mapping exactly as provided; do not replace or remove it. + - Only fill blank existing mappings or add new mappings when supported by the available fields. + - Only include mappings that are clearly semantically equivalent or strongly related by label, name, type, and purpose. + - Do not force one-to-one coverage. Omit Unity fields when no CHEFS field is a sensible match. + - Omit CHEFS fields that do not clearly map to a Unity target field. + - Do not map platform/system identifiers such as SubmissionId, SubmissionDate, or ConfirmationId; they are managed by Unity and should be omitted if present. + - If no fields clearly match, return `{}`. + - The mapping is dynamic; do not hardcode or assume a fixed list of fields. + - Prefer existing Unity core intake fields when they already fit the CHEFS source field. + - Only use worksheet custom field targets when the form genuinely needs them. + - Return valid plain JSON only in the exact OUTPUT shape. + """; + + private const string FormMappingMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing CHEFS fields, Unity core fields, worksheet-derived custom fields, and the existing mapping." + } + """; + + // ── v2/form-worksheet.system.txt ─────────────────────────────────────── + private const string FormWorksheetSystemV2 = """ + You are a custom-field suggestion generator for Unity Grant Manager. + Recommend only the additional fields needed for a Flex worksheet. + Return only valid JSON. + """; + + // ── v2/form-worksheet.user.txt ────────────────────────────────────────── + private const string FormWorksheetUserV2 = """ + WORKSHEET CONTEXT: + {{DATA}} + + OUTPUT + { + "fields": [ + { "key": "", "label": "", "type": "Text" } + ] + } + + Rules: + - Return one field-suggestion JSON object only. + - chefsFields contains the available CHEFS source fields. + - unityCoreFields contains existing Unity core fields. Do not create a custom field when one of these already fits. + - existingMapping contains the current saved Unity-to-CHEFS mappings. Do not duplicate those mappings with a custom field. + - existingCustomFields is a flattened list of fields from worksheets currently linked to this form version. Each entry includes its worksheet name, field name, label, and type. Do not create duplicate custom fields. + - formSchema contains detailed CHEFS control configuration when labels and types need more context. + - Use the provided form context to decide which custom fields are genuinely needed. + - Prefer existing Unity core fields when they already satisfy the need. + - Only create additional worksheet custom fields when the form genuinely needs them. + - Do not include a worksheet title, sections, order, publish state, reporting fields, enabled flag, or field definition. + - Each key and label must be non-empty. Do not repeat a key. + - type must be one of: Text, TextArea, Numeric, Currency, Date, DateTime, Email, Phone, YesNo, Checkbox. Use the type name, never a number. + - Return valid plain JSON only. + """; + + private const string FormWorksheetMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing form metadata, CHEFS fields, Unity core fields, the current mapping, form schema, and custom fields from worksheets currently linked to the form version." + } + """; + + // ── v2/form-scoresheet.system.txt ─────────────────────────────────────── + private const string FormScoresheetSystemV2 = """ + You are a scoresheet definition generator for Unity Grant Manager. + Generate a recommended scoresheet definition JSON that can be imported into Flex. + Return only valid JSON. + """; + + // ── v2/form-scoresheet.user.txt ────────────────────────────────────────── + private const string FormScoresheetUserV2 = """ + SCORESHEET CONTEXT: + {{DATA}} + + OUTPUT + { + "Title": "", + "Name": "", + "Version": , + "Order": 0, + "Published": false, + "ReportColumns": "", + "ReportKeys": "", + "ReportViewName": "", + "Sections": [ + { + "Name": "", + "Order": 0, + "Fields": [ + { + "Name": "", + "Label": "", + "Description": "", + "Order": 0, + "Type": , + "Enabled": true, + "Definition": "" + } + ] + } + ] + } + + Rules: + - Return one scoresheet definition JSON object only. + - The context contains CHEFS form fields, allowed Unity Flex question types, and a scoresheet template. + - Fill out the scoresheet template to generate the rubric assessors use to score submitted applications. + - Use CHEFS form fields as evidence for assessment criteria, but do not create one question per form field. + - Keep the generated scoresheet focused on reviewer criteria, scoring choices, and comments. + - Do not invent assessor workflow, compliance, declaration, approval, status, conflict-of-interest, or submission identifier questions unless the CHEFS fields explicitly contain content that should be scored for that topic. + - Use the template's Name, Version, Order, Published, ReportColumns, ReportKeys, and ReportViewName values. + - Use the numeric QuestionType values from Unity Flex. + - Do not copy or infer an existing scoresheet unless it is explicitly provided as part of the template. + - Return valid plain JSON only. + """; + + private const string FormScoresheetMetadataV2 = """ + { + "DATA": "Serialized JSON payload containing the form name, form version, CHEFS fields, allowed question types, and the scoresheet template to fill out." + } + """; + // ── v1/common.rules.txt ────────────────────────────────────────────────── private const string CommonRules = """ - Any narrative text response must be at least 12 words. 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 443b3602cc..6c9fd867df 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 @@ -1,18 +1,15 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Unity.AI.Automation; using Unity.AI.Features; 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 Unity.GrantManager.GrantApplications.Automation; using Volo.Abp.MultiTenancy; using Volo.Abp; using Volo.Abp.Features; @@ -21,63 +18,85 @@ namespace Unity.AI.Generation; [Route("api/app/ai/generation")] public class AIGenerationAppService( - IApplicationAIGenerationQueue aiGenerationQueue, + IApplicationGenerationQueue 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) + public virtual async Task GenerateApplicationAttachmentSummariesAsync(AttachmentSummaryGenerationRequestDto request) { await featureGuard.EnsureEnabledAsync( AIFeatures.AttachmentSummaries, AILocalizationKeys.AttachmentSummariesDisabled); - if (input.AttachmentIds.Count == 0) + if (request.AttachmentIds.Count == 0) { - return []; + return; } - await aiGenerationQueue.QueueAttachmentSummaryAsync( - input.ApplicationId, + await aiGenerationQueue.QueueApplicationAttachmentSummaryAsync( + request.ApplicationId, currentTenant.Id, - input.PromptVersion, - input.AttachmentIds); - - return input.AttachmentIds - .Select(_ => new AttachmentSummaryResultDto { Completed = false }) - .ToList(); + request.AttachmentIds, + request.PromptVersion); } [Authorize(AIPermissions.Analysis.GenerateApplicationAnalysis)] [HttpPost("application-analysis")] - public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) + public virtual async Task GenerateApplicationAnalysisAsync(Guid applicationId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.ApplicationAnalysis, AILocalizationKeys.ApplicationAnalysisDisabled); await aiGenerationQueue.QueueApplicationAnalysisAsync(applicationId, currentTenant.Id, promptVersion); - return new ApplicationAnalysisResultDto { Completed = false }; } [Authorize(AIPermissions.Analysis.GenerateScoring)] [HttpPost("application-scoring")] - public virtual async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) + public virtual async Task GenerateApplicationScoringAsync(Guid applicationId, string? promptVersion = null) { await featureGuard.EnsureEnabledAsync( AIFeatures.Scoring, AILocalizationKeys.ScoringDisabled); await aiGenerationQueue.QueueApplicationScoringAsync(applicationId, currentTenant.Id, promptVersion); - return new ApplicationScoringResultDto { Completed = false }; + } + + [Authorize(AIPermissions.Analysis.GenerateFormMapping)] + [HttpPost("form-mapping")] + public virtual async Task GenerateFormMappingAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormMapping, + AILocalizationKeys.FormMappingDisabled); + + await aiGenerationQueue.QueueFormMappingAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); + } + + [Authorize(AIPermissions.Analysis.GenerateFormWorksheet)] + [HttpPost("form-worksheet")] + public virtual async Task GenerateFormWorksheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormWorksheet, + AILocalizationKeys.FormWorksheetDisabled); + + await aiGenerationQueue.QueueFormWorksheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); + } + + [Authorize(AIPermissions.Analysis.GenerateFormScoresheet)] + [HttpPost("form-scoresheet")] + public virtual async Task GenerateFormScoresheetAsync(Guid applicationId, Guid applicationFormVersionId, string? promptVersion = null) + { + await featureGuard.EnsureEnabledAsync( + AIFeatures.FormScoresheet, + AILocalizationKeys.FormScoresheetDisabled); + + await aiGenerationQueue.QueueFormScoresheetAsync(applicationId, currentTenant.Id, applicationFormVersionId, promptVersion); } [Authorize] @@ -87,27 +106,34 @@ public virtual async Task GetStatusAsync(Guid application await EnsureStatusAccessAsync(operationType); var request = await aiGenerationStatusAppService.GetLatestAsync(applicationId, operationType, currentTenant.Id); - var state = await aiRateLimiter.GetStateAsync(); + if (request == null) + { + return new AIGenerationStatusDto(); + } 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 + GenerationRequest = new AIGenerationRequestDto + { + 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 + }, + 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 }; } @@ -115,21 +141,15 @@ private async Task EnsureStatusAccessAsync(string operationType) { var permission = operationType switch { - ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, - AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, - ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, - AIGenerationRequestKeyHelper.PipelineOperationType => null, + AIGenerationRequestKeyHelper.ApplicationAnalysisOperationType => AIPermissions.Analysis.ViewApplicationAnalysis, + AIGenerationRequestKeyHelper.AttachmentSummaryOperationType => AIPermissions.Analysis.ViewAttachmentSummary, + AIGenerationRequestKeyHelper.ApplicationScoringOperationType => AIPermissions.Analysis.ViewScoringResult, + AIGenerationRequestKeyHelper.FormMappingOperationType => AIPermissions.Analysis.ViewFormMapping, + AIGenerationRequestKeyHelper.FormWorksheetOperationType => AIPermissions.Analysis.ViewFormWorksheet, + AIGenerationRequestKeyHelper.FormScoresheetOperationType => AIPermissions.Analysis.ViewFormScoresheet, _ => throw new UserFriendlyException($"Unsupported AI generation operation type: {operationType}") }; - if (permission is null) - { - await CheckPolicyAsync(AIPermissions.Analysis.ViewApplicationAnalysis); - await CheckPolicyAsync(AIPermissions.Analysis.ViewAttachmentSummary); - await CheckPolicyAsync(AIPermissions.Analysis.ViewScoringResult); - return; - } - await CheckPolicyAsync(permission); } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs new file mode 100644 index 0000000000..ac57e353b1 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Generation/IApplicationGenerationQueue.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Unity.AI.Generation; + +public interface IApplicationGenerationQueue +{ + Task QueueApplicationAttachmentSummaryAsync(Guid applicationId, Guid? tenantId, List attachmentIds, string? promptVersion = null); + + Task QueueApplicationAnalysisAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + + Task QueueApplicationScoringAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); + + Task QueueFormMappingAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + + Task QueueFormWorksheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + + Task QueueFormScoresheetAsync(Guid applicationId, Guid? tenantId, Guid applicationFormVersionId, string? promptVersion = null); + + Task QueueApplicationIntakeAsync(Guid applicationId, Guid? tenantId, string? promptVersion = null); +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AICooldownService.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AICooldownService.cs new file mode 100644 index 0000000000..136d7d3ae7 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AICooldownService.cs @@ -0,0 +1,99 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Medallion.Threading; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Configuration; +using Volo.Abp; +using Volo.Abp.DependencyInjection; + +namespace Unity.AI.Cooldown; + +public interface IAICooldownService +{ + Task GetRemainingSecondsAsync(Guid userId); + Task EnsureAsync(Guid? userId); + Task StampAsync(Guid? userId); +} + +public class AICooldownService( + IDistributedCache cache, + IConfiguration configuration, + IDistributedLockProvider distributedLockProvider) + : IAICooldownService, ITransientDependency +{ + public async Task GetRemainingSecondsAsync(Guid userId) + { + var raw = await cache.GetStringAsync(KeyFor(userId)); + if (string.IsNullOrEmpty(raw) || + !long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var untilTicks) || + untilTicks < DateTime.MinValue.Ticks || + untilTicks > DateTime.MaxValue.Ticks) + { + return 0; + } + + var seconds = (int)Math.Ceiling((new DateTime(untilTicks, DateTimeKind.Utc) - DateTime.UtcNow).TotalSeconds); + return seconds > 0 ? seconds : 0; + } + + public async Task EnsureAsync(Guid? userId) + { + if (userId is not Guid resolvedUserId) + { + return; + } + + var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + resolvedUserId); + using (await userLock.AcquireAsync()) + { + var remaining = await GetRemainingSecondsAsync(resolvedUserId); + if (remaining > 0) + { + throw new UserFriendlyException( + $"AI generation is rate limited. Try again in {remaining} second{(remaining == 1 ? "" : "s")}."); + } + } + } + + public async Task StampAsync(Guid? userId) + { + if (userId is Guid resolvedUserId) + { + var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + resolvedUserId); + using (await userLock.AcquireAsync()) + { + await StampAsync(resolvedUserId, CooldownSeconds); + } + } + } + + private const string CooldownKeyPrefix = "ai-generation:cooldown:"; + private const string CooldownLockPrefix = "ai-generation:cooldown-lock:"; + private const string CooldownSecondsConfigurationKey = "Azure:Generation:CooldownSeconds"; + + private int CooldownSeconds + { + get + { + var configured = configuration.GetValue(CooldownSecondsConfigurationKey); + if (configured is > 0) + { + return configured.Value; + } + + throw new AbpException($"{CooldownSecondsConfigurationKey} must be configured with a positive value."); + } + } + + private async Task StampAsync(Guid userId, int seconds) + { + var until = DateTime.UtcNow.AddSeconds(seconds); + await cache.SetStringAsync( + KeyFor(userId), + until.Ticks.ToString(CultureInfo.InvariantCulture), + new DistributedCacheEntryOptions { AbsoluteExpiration = until }); + } + + private static string KeyFor(Guid userId) => CooldownKeyPrefix + userId; +} diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs index 2f0f06f309..fe5a68b569 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/RateLimit/AIRateLimiter.cs @@ -86,11 +86,15 @@ public virtual async Task GetStateAsync() return new AIRateLimitStateDto { RetryAfterSeconds = 0, IsGenerating = false }; } - return new AIRateLimitStateDto + var userLock = distributedLockProvider.CreateLock(CooldownLockPrefix + userId); + using (await userLock.AcquireAsync()) { - RetryAfterSeconds = await GetRemainingSecondsAsync(userId), - IsGenerating = await HasActiveGenerationAsync() - }; + return new AIRateLimitStateDto + { + RetryAfterSeconds = await GetRemainingSecondsAsync(userId), + IsGenerating = await HasActiveGenerationAsync() + }; + } } private async Task HasActiveGenerationAsync() diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj index 83d79cee4d..91c741e446 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Application/Unity.AI.Application.csproj @@ -6,22 +6,21 @@ Unity.AI - + - + - - - + + + - - - - - - - - + + + + + + + @@ -29,14 +28,14 @@ - - + + all runtime; build; native; contentfiles; analyzers - + PreserveNewest PreserveNewest diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs index e658c36350..e6ace26df3 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Features/AIFeatures.cs @@ -6,4 +6,7 @@ public static class AIFeatures public const string AttachmentSummaries = "Unity.AI.AttachmentSummaries"; public const string ApplicationAnalysis = "Unity.AI.ApplicationAnalysis"; public const string Scoring = "Unity.AI.Scoring"; + public const string FormMapping = "Unity.AI.FormMapping"; + public const string FormWorksheet = "Unity.AI.FormWorksheet"; + public const string FormScoresheet = "Unity.AI.FormScoresheet"; } 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 3f72aff220..88d3d02728 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 @@ -7,9 +7,15 @@ "Permission:AI.ViewApplicationAnalysis": "View AI Application Analysis", "Permission:AI.ViewAttachmentSummary": "View AI Attachment Summary", "Permission:AI.ViewScoringResult": "View AI Scoring Result", + "Permission:AI.ViewFormMapping": "View AI Form Mapping", + "Permission:AI.ViewFormWorksheet": "View AI Form Worksheet", + "Permission:AI.ViewFormScoresheet": "View AI Form Scoresheet", "Permission:AI.GenerateApplicationAnalysis": "Generate AI Application Analysis", "Permission:AI.GenerateAttachmentSummaries": "Generate AI Attachment Summaries", "Permission:AI.GenerateScoring": "Generate AI Scoring", + "Permission:AI.GenerateFormMapping": "Generate AI Form Mapping", + "Permission:AI.GenerateFormWorksheet": "Generate AI Form Worksheet", + "Permission:AI.GenerateFormScoresheet": "Generate AI Form Scoresheet", "Permission:AI.ConfigureAI": "AI Configuration", "Permission:AI.Prompts": "AI Prompt Management", "Permission:AI.Prompts.Create": "Create Prompts", @@ -23,6 +29,12 @@ "AI:AttachmentSummariesDisabled": "AI attachment summaries are not enabled.", "AI:ApplicationAnalysisDisabled": "AI application analysis is not enabled.", "AI:ScoringDisabled": "AI scoring is not enabled.", + "AI:FormMappingRequiresFormVersion": "AI form mapping requires a valid form version.", + "AI:FormMappingDisabled": "AI form mapping is not enabled.", + "AI:FormWorksheetRequiresFormVersion": "AI form worksheet requires a valid form version.", + "AI:FormWorksheetDisabled": "AI form worksheet is not enabled.", + "AI:FormScoresheetRequiresFormVersion": "AI form scoresheet requires a valid form version.", + "AI:FormScoresheetDisabled": "AI form scoresheet is not enabled.", "AI:GenerateAllDisabled": "AI generation is not enabled.", "AI:NoAttachmentsAvailable": "No attachments are available to summarize.", "AI:ApplicationAnalysisRequiresSubmission": "AI application analysis requires application submission data.", 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 07b526d9a4..3af858cf44 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 @@ -5,6 +5,12 @@ public static class AILocalizationKeys public const string AttachmentSummariesDisabled = "AI:AttachmentSummariesDisabled"; public const string ApplicationAnalysisDisabled = "AI:ApplicationAnalysisDisabled"; public const string ScoringDisabled = "AI:ScoringDisabled"; + public const string FormMappingRequiresFormVersion = "AI:FormMappingRequiresFormVersion"; + public const string FormMappingDisabled = "AI:FormMappingDisabled"; + public const string FormWorksheetRequiresFormVersion = "AI:FormWorksheetRequiresFormVersion"; + public const string FormWorksheetDisabled = "AI:FormWorksheetDisabled"; + public const string FormScoresheetRequiresFormVersion = "AI:FormScoresheetRequiresFormVersion"; + public const string FormScoresheetDisabled = "AI:FormScoresheetDisabled"; public const string GenerateAllDisabled = "AI:GenerateAllDisabled"; public const string NoAttachmentsAvailable = "AI:NoAttachmentsAvailable"; public const string ApplicationAnalysisRequiresSubmission = "AI:ApplicationAnalysisRequiresSubmission"; diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj index f094575dec..30508215fd 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Domain.Shared/Unity.AI.Shared.csproj @@ -10,12 +10,12 @@ - - + + - + @@ -24,8 +24,8 @@ - - + + all runtime; build; native; contentfiles; analyzers 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 50eea177db..deba87c054 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.Navigation; using Unity.Modules.Shared.Specializations; using Unity.Modules.Shared.Permissions; using Volo.Abp.Features; @@ -27,14 +28,13 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex var specializationChecker = context.ServiceProvider.GetRequiredService(); if (!await specializationChecker.IsEnabledAsync(SpecializationConsts.Onboarding)) { - context.Menu.AddItem(new ApplicationMenuItem( + await context.AddItemAsync(new ApplicationMenuItem( name: AIMenus.Prompts, displayName: "AI Prompts", url: "~/Prompts", icon: "fl fl-ai-prompts", - order: 900, - requiredPermissionName: IdentityConsts.ITOperationsPermissionName - )); + order: 900 + ).OnlyWhenInRole(IdentityConsts.ITOperationsRoleName)); } if (await featureChecker.IsEnabledAsync("Unity.AIReporting")) @@ -48,5 +48,6 @@ private static async Task ConfigureMainMenuAsync(MenuConfigurationContext contex requiredPermissionName: AIPermissions.Reporting.ReportingDefault )); } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs index c455ea168c..6cd16e018a 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Menus/AIMenus.cs @@ -6,4 +6,5 @@ public static class AIMenus public const string Prompts = Prefix + ".Prompts"; public const string Reporting = Prefix + ".Reporting"; + public const string FormMapping = Prefix + ".FormMapping"; } diff --git a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml index a7ffb565fb..6e4fd0b435 100644 --- a/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.AI/src/Unity.AI.Web/Pages/AIReporting/Index.cshtml @@ -1,18 +1,9 @@ @page @model Unity.AI.Web.Pages.AIReporting.IndexModel -@section styles { - @if (Model.CanViewAiReporting) - { - - } -} - @section scripts { @if (Model.CanViewAiReporting) { - - 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 1b4d2bddd8..4eb0df8069 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 @@ -22,7 +22,7 @@

@L["AIPrompts"]

- +
@@ -48,7 +48,7 @@
- +
@@ -56,7 +56,7 @@ newOption.className = "option-container"; newOption.id = optionId; newOption.innerHTML = ` - + `; document.getElementById("radioOptions").appendChild(newOption); 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 5c90f25eb6..24f9a42dc4 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 @@ -490,7 +490,7 @@ function savePreviewChanges(questionId, inputFieldPrefix, saveButtonPrefix, disc const saveButton = document.getElementById(saveButtonPrefix + questionId); const discardButton = document.getElementById(discardButtonPrefix + questionId); - inputField.setAttribute('data-original-value', inputField.value); + inputField.dataset.originalValue = inputField.value; saveButton.disabled = true; discardButton.disabled = true; diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml index fabde53c6c..0a3aab55c5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/ScoresheetConfiguration/Default.cshtml @@ -14,7 +14,7 @@
- +

Scoresheets

@@ -34,7 +34,7 @@
- +
Scoresheet filter diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListDefinitionWidget/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListDefinitionWidget/Default.cshtml index 516be6f8f6..cf1ad644fa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListDefinitionWidget/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Flex/src/Unity.Flex.Web/Views/Shared/Components/SelectListDefinitionWidget/Default.cshtml @@ -45,8 +45,8 @@ - - + +
- +

Worksheets

@@ -33,7 +33,7 @@
- +
Published filter diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj index fbe7add032..16ef810103 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Unity.Flex.Application.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -12,20 +12,22 @@ - - - - - - - - - + + + + + + + + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/DefinitionResolverTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/DefinitionResolverTests.cs new file mode 100644 index 0000000000..6a065af65e --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Application.Tests/Worksheets/DefinitionResolverTests.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using Shouldly; +using Unity.Flex; +using Unity.Flex.Worksheets; +using Unity.Flex.Worksheets.Definitions; +using Xunit; + +namespace Unity.Flex.Application.Tests.Worksheets; + +public class DefinitionResolverTests +{ + [Fact] + public void Resolve_Should_Preserve_JsonObject_When_Definition_Is_JsonElement() + { + using var document = JsonDocument.Parse("""{"required":true,"maxLength":100}"""); + + var definition = DefinitionResolver.Resolve(CustomFieldType.Text, document.RootElement); + + definition.ShouldBe("""{"required":true,"maxLength":100}"""); + definition.ConvertDefinition(CustomFieldType.Text)!.Required.ShouldBeTrue(); + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj index bc1afd5896..840463a334 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.TestBase/Unity.Flex.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,30 +9,30 @@ - - - + + + all runtime; build; native; contentfiles; analyzers - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj index ce4d41158a..dac96e2ab9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Unity.Flex.Web.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,24 +10,19 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/Index.cshtml index 51e0555b49..95399c8d69 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/Index.cshtml @@ -38,7 +38,7 @@
- +
diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/PermissionRoleMatrix.cshtml b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/PermissionRoleMatrix.cshtml index 9b86a080f7..a1dd940807 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/PermissionRoleMatrix.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Roles/PermissionRoleMatrix.cshtml @@ -55,7 +55,7 @@

Permission-Role Matrix - @CurrentTenant.Name

- +
diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml index e7fb61ddd3..478a316e7c 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/EditModal.cshtml @@ -22,8 +22,8 @@
- - + + diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/Index.cshtml index 37a9c8a2e3..e00d257dff 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Pages/Identity/Users/Index.cshtml @@ -38,7 +38,7 @@
- +
diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj index ac7d62ebe2..86d284e211 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/src/Unity.Identity.Web.csproj @@ -31,17 +31,15 @@ - - - + + + - - - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj index 4f55729056..cb31380f07 100644 --- a/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Identity.Web/test/Unity.Identity.Web.Tests/Unity.Identity.Web.Tests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,16 +10,16 @@ - + - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj index 0e41f2f1c4..cb37cfb67e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application.Contracts/Unity.Notifications.Application.Contracts.csproj @@ -1,34 +1,33 @@ - - - - - - netstandard2.1;net10.0 - enable - Unity.Notifications - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - - - - + + + + + + netstandard2.1;net10.0 + enable + Unity.Notifications + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs index 1cd1489413..0abcfd1e37 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/EmailNotificaions/EmailNotificationService.cs @@ -1,8 +1,10 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System; +using System.IO; using System.Collections.Generic; using System.Linq; using System.Net; @@ -29,7 +31,8 @@ public class EmailNotificationService( IExternalUserLookupServiceProvider externalUserLookupServiceProvider, ISettingManager settingManager, IFeatureChecker featureChecker, - IHttpContextAccessor httpContextAccessor) : ApplicationService, IEmailNotificationService + IConfiguration configuration, + IWebHostEnvironment webHostEnvironment) : ApplicationService, IEmailNotificationService { public async Task InitializeDraftAsync(Guid applicationId) @@ -75,27 +78,21 @@ protected virtual async Task NotifyTeamsChannel(string chesEmailError) string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); string activityTitle = "CHES Email error: " + chesEmailError; string activitySubtitle = "Environment: " + envInfo; - await notificationAppService.PostToTeamsAsync(activityTitle, activitySubtitle); + await notificationAppService.PostToNotificationsAsync(activityTitle, activitySubtitle); } public Task GetBaseUrlAsync() { - var httpContext = httpContextAccessor.HttpContext - ?? throw new InvalidOperationException("No active HTTP context available to resolve base URL."); - - var request = httpContext.Request; - - var host = request.Headers["X-Forwarded-Host"].FirstOrDefault() - ?? request.Host.Value; - - var scheme = request.Headers["X-Forwarded-Proto"].FirstOrDefault() - ?? request.Scheme; - - var pathBase = request.Headers["X-Forwarded-Prefix"].FirstOrDefault() - ?? request.PathBase.Value - ?? string.Empty; - - return Task.FromResult($"{scheme}://{host}{pathBase}".TrimEnd('/')); + var selfUrl = configuration["App:SelfUrl"]; + + if (string.IsNullOrWhiteSpace(selfUrl)) + { + throw new InvalidOperationException( + "App:SelfUrl configuration is not set. Cannot resolve base URL for email notifications. " + + "Ensure the configuration is properly set in appsettings or environment variables."); + } + + return Task.FromResult(selfUrl.TrimEnd('/')); } public async Task SendCommentNotification(EmailCommentDto input) @@ -129,32 +126,7 @@ public async Task SendCommentNotification(EmailCommentDto i _ => CurrentUser.UserName ?? "Unknown User" }; - string htmlBody = $@" - - -

{currentUserText} mentioned you in a comment.

- - - - -
-

{input.Body}

-
-
- - - - -
- View Comment -
-

*Note - Please do not reply to this email as it is an automated notification.

- - "; + string htmlBody = await RenderCommentNotificationTemplateAsync(currentUserText, input.Body, commentLink); foreach (var email in input.MentionNamesEmail) { @@ -274,4 +246,68 @@ private async Task UpdateTenantSettings(string settingKey, string valueString) await settingManager.SetForCurrentTenantAsync(settingKey, valueString); } } + + /// + /// Renders the comment notification email template with the provided parameters. + /// + /// Display name of the user who mentioned + /// The comment body text (may contain HTML) + /// The URL link to view the comment + /// Rendered HTML email body + private async Task RenderCommentNotificationTemplateAsync(string currentUserText, string commentBody, string commentLink) + { + // Load template from embedded resources or file system + string templateContent = await LoadEmailTemplateAsync("CommentNotification"); + + // Replace placeholders with actual values + var renderedTemplate = templateContent + .Replace("@Model.CurrentUserText", currentUserText) + .Replace("@Html.Raw(Model.CommentBody)", commentBody) + .Replace("@Model.CommentLink", commentLink); + + return renderedTemplate; + } + + /// + /// Loads an email template from the Views/EmailTemplates directory. + /// + /// Template name without extension (e.g., "CommentNotification") + /// Template content as a string + private async Task LoadEmailTemplateAsync(string templateName) + { + try + { + // Content root is at: .../Unity.GrantManager/src/Unity.GrantManager.Web + // We need to go up 2 levels to reach Unity.GrantManager, then into modules + var contentRoot = webHostEnvironment.ContentRootPath; + + var templatePath = Path.Combine( + contentRoot, + "..", + "..", + "modules", + "Unity.Notifications", + "src", + "Unity.Notifications.Web", + "Views", + "EmailTemplates", + $"{templateName}.cshtml"); + + // Normalize the path to remove .. references + templatePath = Path.GetFullPath(templatePath); + + if (!File.Exists(templatePath)) + { + throw new FileNotFoundException($"Email template not found at: {templatePath}"); + } + + var content = await File.ReadAllTextAsync(templatePath); + return content; + } + catch (Exception ex) + { + Logger.LogError(ex, $"Failed to load email template '{templateName}'"); + throw; + } + } } diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs index 42c22f5d17..a68c3109cc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailAttachmentService.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using Unity.Notifications.Emails; +using Volo.Abp.Authorization; using Volo.Abp.DependencyInjection; using Volo.Abp.Users; @@ -77,13 +78,19 @@ public async Task UploadAttachmentAsync( ContentType = contentType, FileSize = fileContent.Length, Time = DateTime.UtcNow, + // Unlike UploadUserAttachmentAsync below, this path is reached from + // EmailNotificationHandler - a local event handler that can run for + // system/schedule-triggered emails with no interactive user in context, so a missing + // ICurrentUser.Id here isn't necessarily an error condition. The caller already wraps + // this in a try/catch that logs and sends the email without the attachment on any + // failure, so Guid.Empty (rather than throwing) is the intentional "no user" marker. UserId = _currentUser.Id ?? Guid.Empty, TenantId = tenantId }; await _emailLogAttachmentRepository.InsertAsync(attachment); return attachment; - } + } public async Task DownloadFromS3Async(string s3ObjectKey) { @@ -143,7 +150,10 @@ public async Task UploadUserAttachmentAsync( ContentType = contentType, FileSize = fileContent.Length, Time = DateTime.UtcNow, - UserId = _currentUser.Id ?? Guid.Empty, + // A missing ICurrentUser.Id means this was reached without an authenticated user - + // fail loudly rather than silently attributing the attachment to Guid.Empty, which + // would look like a valid, specific user rather than an error state. + UserId = _currentUser.Id ?? throw new AbpAuthorizationException("Cannot save an email attachment without an authenticated user."), TenantId = tenantId }; diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs index 1933d5e6da..d5db9a21e3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Emails/EmailLogAttachmentAppService.cs @@ -122,6 +122,12 @@ public async Task GetTotalFileSizeByEmailLogIdAsync(Guid? emailLogId, Guid return await emailAttachmentService.GetTotalFileSizeAsync(emailLogId, templateId); } + // Not exposed over HTTP: this method takes raw fileName/content/contentType with none of the + // allowlist/size/content-type validation AttachmentController enforces before calling it. It + // must only ever be reached in-process, via IEmailLogAttachmentUploadService, from a caller + // (AttachmentController) that has already run those checks - never directly by an HTTP client, + // which would bypass validation entirely despite still needing the Email.Send permission. + [RemoteService(false)] public async Task UploadAsync(Guid? emailLogId, Guid? templateId, Guid? tenantId, string fileName, byte[] content, string contentType) { var attachment = await emailAttachmentService.UploadUserAttachmentAsync(emailLogId, templateId, tenantId, fileName, content, contentType); diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs index 6a954be51a..635e7c1034 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Integrations/RabbitMQ/EmailQueueService.cs @@ -36,7 +36,7 @@ public async Task SendToEmailDelayedQueueAsync(EmailNotificationEvent emai await Task.Delay(TimeSpan.FromMilliseconds(FiveMinutesInMilliSeconds * (emailNotificationEvent.RetryAttempts + 1))); - _queueProducer.PublishMessage(message); + await _queueProducer.PublishMessageAsync(message); } catch (Exception ex) { var ExceptionMessage = ex.Message; @@ -45,7 +45,7 @@ public async Task SendToEmailDelayedQueueAsync(EmailNotificationEvent emai return Task.CompletedTask; } - public Task SendToEmailEventQueueAsync(EmailNotificationEvent emailNotificationEvent) + public async Task SendToEmailEventQueueAsync(EmailNotificationEvent emailNotificationEvent) { try { @@ -55,13 +55,11 @@ public Task SendToEmailEventQueueAsync(EmailNotificationEvent emailNotificationE TenantId = emailNotificationEvent.TenantId ?? Guid.Empty, EmailNotificationEvent = emailNotificationEvent }; - _queueProducer.PublishMessage(message); + await _queueProducer.PublishMessageAsync(message); } catch (Exception ex) { var ExceptionMessage = ex.Message; _logger.LogError(ex, "SendToEmailEventQueueAsync Exception: {ExceptionMessage}", ExceptionMessage); } - - return Task.CompletedTask; } } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/LogNotifications/LogNotificationService.cs similarity index 68% rename from applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs rename to applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/LogNotifications/LogNotificationService.cs index cd89042b76..2df00dceea 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/TeamsNotifications/TeamsNotificationService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/LogNotifications/LogNotificationService.cs @@ -1,156 +1,156 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Unity.GrantManager.Notifications.Teams; - - -namespace Unity.Notifications.Teams -{ - public class TeamsNotificationService - { - public TeamsNotificationService() : base() { } - - public const string DIRECT_MESSAGE_KEY_PREFIX = "DIRECT_MESSAGE_"; - public const string TEAMS_ALERT = $"{DIRECT_MESSAGE_KEY_PREFIX}0"; - public const string TEAMS_NOTIFICATION = $"{DIRECT_MESSAGE_KEY_PREFIX}1"; - - public static string TeamsChannel { get; set; } = string.Empty; - - private readonly List _facts = []; - - public async Task PostFactsToTeamsAsync(string teamsChannel, string activityTitle, string activitySubtitle) - { - if (!teamsChannel.IsNullOrEmpty()) - { - string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, _facts); - await PostToTeamsChannelAsync(teamsChannel, messageCard); - } - } - - public static async Task PostToTeamsAsync(string teamsChannel, string activityTitle, string activitySubtitle, List facts) - { - if(!teamsChannel.IsNullOrEmpty()) { - string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, facts); - await PostToTeamsChannelAsync(teamsChannel, messageCard); - } - } - - public List AddFact(string Name, string Value) - { - var fact = new Fact - { - Name = Name, - Value = Value - }; - - _facts.Add(fact); - return _facts; - } - - private static class ChefsEventTypesConsts - { - public const string FORM_PUBLISHED = "eventFormPublished"; - public const string FORM_UN_PUBLISHED = "eventFormUnPublished"; - public const string FORM_DRAFT_PUBLISHED = "eventFormDraftPublished"; - } - - public static string InitializeMessageCard(string activityTitle, string activitySubtitle, List facts) - { - dynamic messageCard = MessageCard.GetMessageCard(); - JObject jsonObj = JsonConvert.DeserializeObject(messageCard)!; - string messageCardString = string.Empty; - - if(jsonObj != null) - { - jsonObj["summary"] = "Message Summary"; - - if(jsonObj["sections"] != null) - { - var sections = jsonObj["sections"]; - var firstChild = sections?.Children().First(); - - if (firstChild != null) - { - firstChild["activityTitle"] = activityTitle; - firstChild["activitySubtitle"] = activitySubtitle; - // Add Facts - foreach (var fact in facts) - { - JObject obj = JObject.Parse(JsonConvert.SerializeObject(fact)); - firstChild.Value("facts")?.Add(obj); - } - } - } - - messageCardString = jsonObj.ToString(Formatting.None); - } - - return messageCardString; - } - - public static async Task PostChefsEventToTeamsAsync(string teamsChannel, string subscriptionEvent, dynamic form, dynamic chefsFormVersion) - { - string eventDescription = subscriptionEvent switch - { - ChefsEventTypesConsts.FORM_DRAFT_PUBLISHED => "A Draft CHEFS form was published", - ChefsEventTypesConsts.FORM_PUBLISHED => "A CHEFS form was published", - ChefsEventTypesConsts.FORM_UN_PUBLISHED => "A CHEFS form was un-published", - _ => "An Unknown CHEFS event " + subscriptionEvent + " was fired " - }; - - JObject formObject = JObject.Parse(form.ToString()); - var formName = formObject.SelectToken("name"); - - // version - JToken? version = ((JObject)chefsFormVersion).SelectToken("version"); - JToken? published = ((JObject)chefsFormVersion).SelectToken("published"); - JToken? createdBy = ((JObject)chefsFormVersion).SelectToken("createdBy"); - JToken? createdAt = ((JObject)chefsFormVersion).SelectToken("createdAt"); - JToken? updatedBy = ((JObject)chefsFormVersion).SelectToken("updatedBy"); - JToken? updatedAt = ((JObject)chefsFormVersion).SelectToken("updatedAt"); - - string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - string activityTitle = eventDescription + " with an event posting to the " + envInfo + " environment"; - - string activitySubtitle = "Form Name: " + formName?.ToString(); - - // Fix for IDE0028: Simplify collection initialization - List facts = - [ - new Fact { Name = "Form Version: ", Value = version?.ToString() ?? string.Empty }, - new Fact { Name = "Published: ", Value = published?.ToString() ?? string.Empty }, - new Fact { Name = "Updated By: ", Value = updatedBy?.ToString() ?? string.Empty }, - new Fact { Name = "Updated At: ", Value = updatedAt?.ToString() + " UTC" }, - new Fact { Name = "Created By: ", Value = createdBy?.ToString() ?? string.Empty }, - new Fact { Name = "Created At: ", Value = createdAt?.ToString() + " UTC" } - ]; - - await PostToTeamsAsync(teamsChannel, activityTitle, activitySubtitle, facts); - } - - private static readonly HttpClient httpClient = new(); - - /// - /// Posts a message card to the specified Microsoft Teams channel using an HTTP POST request. - /// - /// The webhook URL of the Teams channel. - /// The message card payload in JSON format. - public static async Task PostToTeamsChannelAsync(string teamsChannel, string messageCard) - { - using var request = new HttpRequestMessage(HttpMethod.Post, teamsChannel); - request.Content = new StringContent(messageCard); - request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); - var response = await httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - // Optionally log or throw an exception here - throw new HttpRequestException($"Failed to post to Teams channel. Status code: {response.StatusCode}"); - } - } - } -} +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Unity.GrantManager.Notifications.Logs; + + +namespace Unity.Notifications.Teams +{ + public class LogNotificationService + { + public LogNotificationService() : base() { } + + public const string DIRECT_MESSAGE_KEY_PREFIX = "DIRECT_MESSAGE_"; + public const string TEAMS_ALERT = $"{DIRECT_MESSAGE_KEY_PREFIX}0"; + public const string TEAMS_NOTIFICATION = $"{DIRECT_MESSAGE_KEY_PREFIX}1"; + + + + private readonly List _facts = []; + + public async Task LogFactsToNotificationsAsync(NotificationType NotificationType, string activityTitle, string activitySubtitle) + { + + string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, _facts); + await PostToNotificationsChannelAsync(NotificationType, messageCard); + } + + public static async Task PostToNotificationsAsync(NotificationType NotificationType, string activityTitle, string activitySubtitle, List facts) + { + string messageCard = InitializeMessageCard(activityTitle, activitySubtitle, facts); + await PostToNotificationsChannelAsync(NotificationType, messageCard); + + } + + public List AddFact(string Name, string Value) + { + var fact = new Fact + { + Name = Name, + Value = Value + }; + + _facts.Add(fact); + return _facts; + } + + private static class ChefsEventTypesConsts + { + public const string FORM_PUBLISHED = "eventFormPublished"; + public const string FORM_UN_PUBLISHED = "eventFormUnPublished"; + public const string FORM_DRAFT_PUBLISHED = "eventFormDraftPublished"; + } + + public static string InitializeMessageCard(string activityTitle, string activitySubtitle, List facts) + { + dynamic messageCard = MessageCard.GetMessageCard(); + JObject jsonObj = JsonConvert.DeserializeObject(messageCard)!; + string messageCardString = string.Empty; + + if(jsonObj != null) + { + jsonObj["summary"] = "Message Summary"; + + if(jsonObj["sections"] != null) + { + var sections = jsonObj["sections"]; + var firstChild = sections?.Children().First(); + + if (firstChild != null) + { + firstChild["activityTitle"] = activityTitle; + firstChild["activitySubtitle"] = activitySubtitle; + // Add Facts + foreach (var fact in facts) + { + JObject obj = JObject.Parse(JsonConvert.SerializeObject(fact)); + firstChild.Value("facts")?.Add(obj); + } + } + } + + messageCardString = jsonObj.ToString(Formatting.None); + } + + return messageCardString; + } + + public static async Task PostChefsEventToNotificationsAsync(NotificationType notificationType, string subscriptionEvent, dynamic form, dynamic chefsFormVersion) + { + string eventDescription = subscriptionEvent switch + { + ChefsEventTypesConsts.FORM_DRAFT_PUBLISHED => "A Draft CHEFS form was published", + ChefsEventTypesConsts.FORM_PUBLISHED => "A CHEFS form was published", + ChefsEventTypesConsts.FORM_UN_PUBLISHED => "A CHEFS form was un-published", + _ => "An Unknown CHEFS event " + subscriptionEvent + " was fired " + }; + + JObject formObject = JObject.Parse(form.ToString()); + var formName = formObject.SelectToken("name"); + + // version + JToken? version = ((JObject)chefsFormVersion).SelectToken("version"); + JToken? published = ((JObject)chefsFormVersion).SelectToken("published"); + JToken? createdBy = ((JObject)chefsFormVersion).SelectToken("createdBy"); + JToken? createdAt = ((JObject)chefsFormVersion).SelectToken("createdAt"); + JToken? updatedBy = ((JObject)chefsFormVersion).SelectToken("updatedBy"); + JToken? updatedAt = ((JObject)chefsFormVersion).SelectToken("updatedAt"); + + string? envInfo = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + string activityTitle = eventDescription + " with an event posting to the " + envInfo + " environment"; + + string activitySubtitle = "Form Name: " + formName?.ToString(); + + // Fix for IDE0028: Simplify collection initialization + List facts = + [ + new Fact { Name = "Form Version: ", Value = version?.ToString() ?? string.Empty }, + new Fact { Name = "Published: ", Value = published?.ToString() ?? string.Empty }, + new Fact { Name = "Updated By: ", Value = updatedBy?.ToString() ?? string.Empty }, + new Fact { Name = "Updated At: ", Value = updatedAt?.ToString() + " UTC" }, + new Fact { Name = "Created By: ", Value = createdBy?.ToString() ?? string.Empty }, + new Fact { Name = "Created At: ", Value = createdAt?.ToString() + " UTC" } + ]; + + await PostToNotificationsAsync(notificationType, activityTitle, activitySubtitle, facts); + } + + private static readonly HttpClient httpClient = new(); + + /// + /// Posts a message card to the specified Notifications channel using an HTTP POST request. + /// + /// The type of notification. + /// The message card payload in JSON format. + public static async Task PostToNotificationsChannelAsync(NotificationType notificationType, string messageCard) + { + // using var request = new HttpRequestMessage(HttpMethod.Post, ""); + // request.Content = new StringContent(messageCard); + // request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); + // var response = await httpClient.SendAsync(request); + // if (!response.IsSuccessStatusCode) + // { + // // Optionally log or throw an exception here + // throw new HttpRequestException($"Failed to post to Notifications channel. Status code: {response.StatusCode}"); + // } + + // REWRITE this - sends to teems channel but we can't anymore + // Would like this to create a push notification to the Unity Notifications service which will then send to Teams channel + } + } +} diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj index 0e50decfcd..7a8eacb9a4 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Application/Unity.Notifications.Application.csproj @@ -9,21 +9,19 @@ - - - - - - + + + + + + - - - - - - - - + + + + + + @@ -31,8 +29,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj index 2eb1af44f6..5dcad10b53 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain.Shared/Unity.Notifications.Domain.Shared.csproj @@ -10,14 +10,14 @@ - - - - + + + + - + @@ -26,8 +26,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj index a4cf76b437..926f8cad23 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Domain/Unity.Notifications.Domain.csproj @@ -9,16 +9,16 @@ - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs index 4d457fb2d9..c9e2a94ab5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/EntityFrameworkCore/GrantManagerDbContext.cs @@ -6,12 +6,12 @@ namespace Unity.Notifications.EntityFrameworkCore; [ConnectionStringName("Default")] -public class GrantManagerDbContext : AbpDbContext +public class GrantManagerDbContext : AbpDbContext { public DbSet DynamicUrls { get; set; } // Add DbSet for each Aggregate Root here. - public GrantManagerDbContext(DbContextOptions options) + public GrantManagerDbContext(DbContextOptions options) : base(options) { diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj index 35443ef745..02618456b9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.EntityFrameworkCore/Unity.Notifications.EntityFrameworkCore.csproj @@ -9,16 +9,16 @@ - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj index 451ddaea79..c8c944ee7e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi.Client/Unity.Notifications.HttpApi.Client.csproj @@ -9,9 +9,9 @@ - - - + + + @@ -21,8 +21,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj index 4c17656712..7e309f547b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.HttpApi/Unity.Notifications.HttpApi.csproj @@ -9,15 +9,15 @@ - - - + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj index a6e97532ec..f9bdac728d 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Installer/Unity.Notifications.Installer.csproj @@ -10,8 +10,8 @@ - - + + @@ -23,8 +23,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj index 3e110d2d7d..b5223b81ad 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Unity.Notifications.Web.csproj @@ -12,15 +12,13 @@ - + - - - - - - - + + + + + @@ -29,7 +27,7 @@ - + @@ -56,8 +54,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/EmailTemplates/CommentNotification.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/EmailTemplates/CommentNotification.cshtml new file mode 100644 index 0000000000..703a316737 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/EmailTemplates/CommentNotification.cshtml @@ -0,0 +1,46 @@ +@model dynamic + + + + + + Comment Notification + + +

@Model.CurrentUserText mentioned you in a comment.

+ + + + + + + + + + + +
Comment
+

@Html.Raw(Model.CommentBody)

+
+
+ + + + + + + + + + + +
Action
+ View Comment +
+

*Note - Please do not reply to this email as it is an automated notification.

+ + diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml index 1fc31ed1c2..015e4a3e13 100644 --- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.cshtml @@ -63,7 +63,7 @@
- +
@@ -61,7 +61,7 @@ Payment ID Prefix - +
@@ -84,7 +84,7 @@ -
+
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.js index 60a2e62727..e5c8a669e5 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentConfigurations/Index.js @@ -8,8 +8,6 @@ $(function () { const formatter = createNumberFormatter(); const l = abp.localization.getResource('GrantManager'); - toastr.options.positionClass = 'toast-top-center'; - const UIElements = { accountCodingDT: $('#AccountCodesDataTable'), @@ -391,12 +389,12 @@ $(function () { function updatePaymentPrefix() { unity.payments.paymentConfigurations.paymentConfiguration.updatePaymentPrefix(UIElements.paymentPrefixInput.val()) .done(function () { - toastr.success('Payment prefix updated successfully.'); + abp.notify.success('Payment prefix updated successfully.'); $('#payment-id-prefix-original').val(UIElements.paymentPrefixInput.val()); checkEnableDiscard(); }) .fail(function () { - toastr.error('Failed to update payment prefix.'); + abp.notify.error('Failed to update payment prefix.'); }); }; @@ -412,7 +410,7 @@ $(function () { function discardPaymentPrefix() { UIElements.paymentPrefixInput.val(UIElements.originalPaymentPrefix.val()); - toastr.info('Payment prefix changes discarded.'); + abp.notify.info('Payment prefix changes discarded.'); checkEnableDiscard(); }; @@ -427,10 +425,10 @@ function clearFilter() { function handleDefaultAccountCodeRadioClick(id) { $('#AccountCodingId').val(id); // Update the hidden input with the selected account code ID unity.payments.paymentConfigurations.paymentConfiguration.setDefaultAccountCode(id).done(function () { - toastr.success('Successfully set default account code. Reloading account codes.'); - clearAccountCodesSearchAndReload(); + abp.notify.success('Successfully set default account code. Reloading account codes.'); + clearAccountCodesSearchAndReload(); }).fail(function () { - toastr.error('Failed to set default account code.'); + abp.notify.error('Failed to set default account code.'); }); }; diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs index 79fbb38582..8e559f42e0 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CasPaymentRequestResponse.cshtml.cs @@ -18,7 +18,8 @@ public ActionResult OnGet(string casResponse) string pattern = ";"; string replace = "
"; - string formattedResponse = Regex.Replace(casResponse, + string encoded = System.Net.WebUtility.HtmlEncode(casResponse); + string formattedResponse = Regex.Replace(encoded, pattern, replace, RegexOptions.None, diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js index 5d19c2279d..6a11c45d51 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/CreatePaymentRequestsModal.js @@ -57,7 +57,7 @@ function checkMaxValueRequest(applicationId, input, amountRemaining) { validateParentChildAmounts(applicationId); } else { // Use existing remaining amount validation - let enteredValue = Number.parseFloat(input.value.replace(/,/g, '')); + let enteredValue = Number.parseFloat(input.value.replaceAll(',', '')); let remainingErrorId = '#error_column_' + applicationId; if (amountRemaining < enteredValue) { $(remainingErrorId).css('display', 'block'); @@ -88,13 +88,13 @@ function validateAllPaymentAmounts() { let amountInput = $( `input[name="ApplicationPaymentRequestForm[${index}].Amount"]` ); - let remainingAmount = parseFloat( + let remainingAmount = Number.parseFloat( $( `input[name="ApplicationPaymentRequestForm[${index}].RemainingAmount"]` ).val() ); let enteredValue = - parseFloat(amountInput.val().replace(/,/g, '')) || 0; + Number.parseFloat(amountInput.val().replaceAll(',', '')) || 0; let remainingErrorId = `#error_column_${correlationId}`; if (enteredValue > remainingAmount) { @@ -127,7 +127,7 @@ function submitPayments() { function calculateTotalAmount() { let total = 0; $('.amount').each(function () { - let value = parseFloat($(this).val().replace(/,/g, '')) || 0; + let value = Number.parseFloat($(this).val().replaceAll(',', '')) || 0; total += value; }); @@ -146,7 +146,7 @@ function getIndexByCorrelationId(correlationId) { .attr('name') .match(/\[(\d+)\]/); if (match) { - index = parseInt(match[1], 10); + index = Number.parseInt(match[1], 10); } return false; // break } @@ -170,7 +170,7 @@ function formatCurrency(value) { const numericValue = typeof value === 'number' ? value - : parseFloat(String(value ?? '').replace(/,/g, '')); + : Number.parseFloat(String(value ?? '').replaceAll(',', '')); return cadFormatter.format( Number.isFinite(numericValue) ? numericValue : 0 ); @@ -193,10 +193,10 @@ function validateParentChildAmounts(correlationId) { `input[name="ApplicationPaymentRequestForm[${index}].ParentApprovedAmount"]` ).val(); let maximumAllowed = maximumAllowedInput - ? parseFloat(maximumAllowedInput) + ? Number.parseFloat(maximumAllowedInput) : 0; let approvedAmount = parentApprovedAmount - ? parseFloat(parentApprovedAmount) + ? Number.parseFloat(parentApprovedAmount) : 0; // Determine if this is a parent or child @@ -231,7 +231,7 @@ function validateParentChildAmounts(correlationId) { let amountInput = $( `input[name="ApplicationPaymentRequestForm[${itemIndex}].Amount"]` ); - let amount = Number.parseFloat(amountInput.val().replace(/,/g, '')) || 0; + let amount = Number.parseFloat(amountInput.val().replaceAll(',', '')) || 0; groupTotal += amount; } }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml index ba5d3c1f8a..4af9908876 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.cshtml @@ -17,8 +17,8 @@ } -
- @await Component.InvokeAsync("PaymentActionBar") +
+ @await Component.InvokeAsync("PaymentActionBar", new { showDateRangeFilter = true })
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 814119e4a9..b9c1c99a52 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 @@ -1,4 +1,45 @@ -#PaymentRequestListTable_filter label { +.date-input-filter-div { + display: inline-block; + padding: 10px; + padding-top: 0px; + margin-top: -10px; + margin-bottom: -10px; + padding-bottom: 0px !important; +} + +.date-input-filter-div label.form-label { + font-size: 13.6px; + margin-bottom: 0.3rem; +} + +.custom-date-range-container-div { + display: inline-block; + padding: 0px; + margin: 0px; +} + +.quick-date-input { + font-size: var(--bc-font-size); + color: var(--bc-colors-grey-text-500); + border-radius: var(--bc-layout-margin-small) !important; + border: 2px solid var(--bc-colors-blue-primary); + text-overflow: ellipsis; +} + +.action-bar-date-controls .search-action-bar { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.action-bar-date-controls .date-input-filter-div { + margin-top: 10px; +} + +.action-bar-date-controls #dynamicButtonContainerId { + margin-top: 0; +} + +#PaymentRequestListTable_filter label { font-size: var(--bc-font-size-sm); color: var(--bc-colors-grey-text-300); white-space: nowrap; 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 ab6f6489c7..940ffdbc6a 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 @@ -2,12 +2,168 @@ $(function () { const l = abp.localization.getResource('Payments'); const nullPlaceholder = '—'; const requestedFieldsStorageKey = 'PaymentRequests_RequestedFields'; + const defaultQuickDateRange = 'last6months'; 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'); let dataTable; let isApprove = false; + let paymentTableFilters = { + requestedFromDate: null, + requestedToDate: null + }; + + const UIElements = { + quickDateRange: $('#quickDateRange'), + inputFilter: $('.date-input-filter'), + requestedToInput: $('#requestedToDate'), + requestedFromInput: $('#requestedFromDate'), + }; + + function toggleCustomDateInputs(show) { + if (show) { + $('#customDateInputs').show(); + } else { + $('#customDateInputs').hide(); + } + } + + function setDateRangeFilters(quickDateRange, range) { + UIElements.quickDateRange.val(quickDateRange); + + if (range) { + const fromDate = range.fromDate ?? ''; + const toDate = range.toDate ?? ''; + UIElements.requestedFromInput.val(fromDate); + UIElements.requestedToInput.val(toDate); + paymentTableFilters.requestedFromDate = fromDate; + paymentTableFilters.requestedToDate = toDate; + } + } + + function setDateRangeLocalStorage(quickDateRange, fromToRange) { + localStorage.setItem('PaymentRequests_QuickRange', quickDateRange || defaultQuickDateRange); + if (fromToRange) { + const fromDate = fromToRange.fromDate; + const toDate = fromToRange.toDate; + if (fromDate) { + localStorage.setItem('PaymentRequests_FromDate', fromDate); + } + else { + localStorage.removeItem('PaymentRequests_FromDate'); + } + if (toDate) { + localStorage.setItem('PaymentRequests_ToDate', toDate); + } + else { + localStorage.removeItem('PaymentRequests_ToDate'); + } + } + } + + function initializeRequestedFilterDates() { + let savedQuickRange = localStorage.getItem('PaymentRequests_QuickRange') || defaultQuickDateRange; + let savedFromDate = localStorage.getItem('PaymentRequests_FromDate'); + let savedToDate = localStorage.getItem('PaymentRequests_ToDate'); + + let isCustomRange = savedQuickRange === 'custom'; + toggleCustomDateInputs(isCustomRange); + + let range = isCustomRange + ? { + fromDate: savedFromDate || '', + toDate: savedToDate || '' + } + : getDateRange(savedQuickRange); + + if (!isCustomRange && !range) { + savedQuickRange = defaultQuickDateRange; + range = getDateRange(savedQuickRange); + } + + setDateRangeFilters(savedQuickRange, range); + setDateRangeLocalStorage(savedQuickRange, range); + + // Set max date to today for both inputs + const today = formatDate(new Date()); + UIElements.requestedToInput.attr({ 'max': today }); + UIElements.requestedFromInput.attr({ 'max': today }); + } + + function handleInputFilterChange() { + const $input = $(this); + const dateValue = $input.val(); + + if (!validateDate(dateValue, $input)) return; + + paymentTableFilters.requestedFromDate = UIElements.requestedFromInput.val(); + paymentTableFilters.requestedToDate = UIElements.requestedToInput.val(); + + // If the values for FromDate and ToDate are being set outside of the + // quick drop down handler, custom SHOULD be shown, but set just in case + UIElements.quickDateRange.val('custom'); + localStorage.setItem('PaymentRequests_QuickRange', 'custom'); + + localStorage.setItem('PaymentRequests_FromDate', paymentTableFilters.requestedFromDate); + localStorage.setItem('PaymentRequests_ToDate', paymentTableFilters.requestedToDate); + + dataTable.ajax.reload(null, true); + } + + function handleQuickDateRangeChange() { + const selectedRange = $(this).val(); + + if (selectedRange === 'custom') { + // Show the custom date inputs and don't modify their values + toggleCustomDateInputs(true); + return; + } + + // Hide custom date inputs for preset ranges + toggleCustomDateInputs(false); + + // Get the date range for the selected option + const range = getDateRange(selectedRange); + setDateRangeFilters(selectedRange, range); + setDateRangeLocalStorage(selectedRange, range); + + // Reload the table with new filters + dataTable.ajax.reload(null, true); + } + + function bindUIEvents() { + UIElements.inputFilter.on('change', handleInputFilterChange); + UIElements.quickDateRange.on('change', handleQuickDateRangeChange); + } + + // Restores search value and date range filters when a saved view is loaded. + function restoreCustomFilters(filters) { + $('#search').val(filters.externalSearchValue || ''); + + let quickRange = filters.quickDateRange || defaultQuickDateRange; + let isCustomRange = filters.quickDateRange === 'custom'; + toggleCustomDateInputs(isCustomRange); + + let range = isCustomRange + ? { + fromDate: filters.requestedFromDate || '', + toDate: filters.requestedToDate || '' + } + : getDateRange(quickRange); + + if (!isCustomRange && !range) { + quickRange = defaultQuickDateRange; + range = getDateRange(quickRange); + } + + setDateRangeFilters(quickRange, range); + setDateRangeLocalStorage(quickRange, range); + } + + bindUIEvents(); + initializeRequestedFilterDates(); + const listColumns = getColumns(); const defaultVisibleColumns = [ 'select', @@ -87,10 +243,10 @@ $(function () { .done(() => { abp.notify.success('The Status Check has been sent for verification to CFS. Please refresh this page to check for Status updates.'); $(".select-all-payments").prop("checked", false); - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }) @@ -159,10 +315,10 @@ $(function () { .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(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); dataTable.ajax.reload(null, false); @@ -247,7 +403,16 @@ $(function () { $('.dt-search input').val(''); $('#search').val(''); - dt.search('').order(initialSortOrder).draw(); + dt.search('').order(initialSortOrder); + + // Reset date range filters + const range = getDateRange(defaultQuickDateRange); + setDateRangeFilters(defaultQuickDateRange, range); + setDateRangeLocalStorage(defaultQuickDateRange, range); + toggleCustomDateInputs(false); + + // Reload table data with updated filters + dt.ajax.reload(null, false); } }, { extend: 'removeAllStates', text: 'Delete All Views' }, @@ -332,7 +497,9 @@ $(function () { } return { - requestedFields: requestedFields + requestedFields: requestedFields, + requestedFromDate: paymentTableFilters.requestedFromDate, + requestedToDate: paymentTableFilters.requestedToDate }; }, responseCallback, @@ -347,19 +514,23 @@ $(function () { fixedHeaders: true, onStateSaveParams: function (settings, data) { data.customFilters = { - externalSearchValue: $('#search').val() || '' + externalSearchValue: $('#search').val() || '', + quickDateRange: UIElements.quickDateRange.val(), + requestedFromDate: UIElements.requestedFromInput.val(), + requestedToDate: UIElements.requestedToInput.val() }; }, onStateLoadParams: function (settings, data) { if (!initialLoad) { isRestoringState = true; if (data?.customFilters) { - $('#search').val(data.customFilters.externalSearchValue || ''); + restoreCustomFilters(data.customFilters); } } }, onStateLoaded: function (dtApi, data) { - if (!initialLoad) { + if (!initialLoad && data) { + // A saved state was restored isRestoringState = false; dtApi.ajax.reload(null, false); } @@ -369,9 +540,17 @@ $(function () { contextMenuActionsSelector: '[data-selector="batch-payment-table-actions"]' }); - $('.grp-savedStates').text('Save View'); + // Initialize savedStates button styling $('.grp-savedStates').closest('.btn-group').addClass('cstm-save-view'); + // Update button text to Save View + function updateSavedStatesButtonText() { + $('.grp-savedStates').text('Save View'); + } + + dataTable.on('stateRestore-change', updateSavedStatesButtonText); + updateSavedStatesButtonText(); + dataTable.on('column-visibility.dt', function (e, settings, columnIdx) { try { const cols = dataTable.settings()[0].aoColumns; @@ -413,10 +592,7 @@ $(function () { ? dataTable.buttons(['.payment-cancel']) : null; - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + checkActionButtons(); dataTable.on('search.dt', () => handleSearch()); function checkAllRowsHaveState(states) { @@ -492,51 +668,36 @@ $(function () { } function checkActionButtons() { - let isInSentState = checkAllRowsHaveState(['Submitted', 'FSB']); - if (isInSentState) { - payment_check_status_buttons.enable(); - } else { - payment_check_status_buttons.disable(); - } + const hasSelection = dataTable.rows({ selected: true }).indexes().length > 0; + let isInSentState = hasSelection && checkAllRowsHaveState(['Submitted', 'FSB']); + setActionButtonState(payment_check_status_buttons, isInSentState); + let hasHistoricalPayment = dataTable.rows('.selected').data().toArray().some(row => row.status === 'HistoricalPayment'); 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')); - if (canApprove) { - payment_approve_buttons.enable(); - } else { - payment_approve_buttons.disable(); - } + setActionButtonState(payment_approve_buttons, canApprove); + checkEnableHistoryButton(dataTable, history_button); if (cancel_button) { const eligibleCancelStatuses = ['HistoricalPayment', 'L1Pending', 'L2Pending', 'L3Pending']; const selectedCount = dataTable.rows({ selected: true }).indexes().length; + let canCancel = false; 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 { - cancel_button.disable(); + canCancel = eligibleCancelStatuses.includes(rowData.status); } + setActionButtonState(cancel_button, canCancel); } } function handleSearch() { - let filterValue = $('.dt-search input').val(); - if (filterValue !== undefined && filterValue.length > 0) { - Array.from(document.getElementsByClassName('selected')).forEach( - function (element, index, array) { - element.classList.toggle('selected'); - } - ); - PubSub.publish("deselect_batchpayment_application", "reset_data"); + const filterValue = dataTable.search(); + if (filterValue?.length > 0) { + dataTable.rows({ selected: true }).deselect(); } } @@ -837,7 +998,14 @@ $(function () { index: columnIndex, render: function (data) { if (data + "" !== "undefined" && data?.length > 0) { - return ''; + const escaped = data + .replaceAll('\\', String.raw`\\`) + .replaceAll("'", String.raw`\'`) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + return ''; } return null; } @@ -1052,10 +1220,10 @@ $(function () { ); dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); }); @@ -1112,10 +1280,10 @@ $(function () { (msg, data) => { dataTable.ajax.reload(null, false); $(".select-all-payments").prop("checked", false); - payment_approve_buttons.disable(); - payment_check_status_buttons.disable(); - history_button.disable(); - if (cancel_button) cancel_button.disable(); + setActionButtonState(payment_approve_buttons, false); + setActionButtonState(payment_check_status_buttons, false); + setActionButtonState(history_button, false); + setActionButtonState(cancel_button, false); selectedPaymentIds = []; PubSub.publish("deselect_batchpayment_application", "reset_data"); PubSub.publish('clear_selected_payment'); @@ -1124,6 +1292,98 @@ $(function () { }); +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// Returns a formatted { fromDate, toDate } for the filter fields. +// Null if 'custom' or no input provided (assumes custom is default break) +function getDateRange(rangeType) { + let today = new Date(); + const toDate = formatDate(new Date()); + let fromDate; + + switch (rangeType) { + case 'today': + fromDate = toDate; + break; + case 'last7days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 7))); + break; + case 'last30days': + fromDate = formatDate(new Date(today.setDate(today.getDate() - 30))); + break; + case 'last3months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 3))); + break; + case 'last6months': + fromDate = formatDate(new Date(today.setMonth(today.getMonth() - 6))); + break; + case 'currentfiscalyear': { + const currentMonth = today.getMonth(); + const currentYear = today.getFullYear(); + const fiscalStartYear = currentMonth >= 3 ? currentYear : currentYear - 1; + return { + fromDate: formatDate(new Date(fiscalStartYear, 3, 1)), + toDate: formatDate(new Date(fiscalStartYear + 1, 2, 31)) + }; + } + case 'alltime': + return { fromDate: null, toDate: null }; + case 'custom': + default: + return null; // Don't modify dates for custom + } + + return { fromDate, toDate }; +} + +function validateDate(dateValue, element) { + if (dateValue) { + const selectedDate = new Date(dateValue); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const minDate = element.attr('min') ? new Date(element.attr('min')) : null; + const maxDate = element.attr('max') ? new Date(element.attr('max')) : null; + + if (selectedDate > today) { + element.addClass('input-validation-error'); + abp.notify.error('The date cannot be in the future', 'Invalid Date'); + return false; + } + + if (minDate && selectedDate < minDate) { + element.addClass('input-validation-error'); + abp.notify.error('The date cannot be before the minimum allowed date', 'Invalid Date'); + return false; + } + + if (maxDate && selectedDate > maxDate) { + element.addClass('input-validation-error'); + abp.notify.error('The date cannot be after the maximum allowed date', 'Invalid Date'); + return false; + } + + element.removeClass('input-validation-error'); + return true; + } + return true; +} + +function setActionButtonState(buttonApi, enabled) { + if (!buttonApi) return; + if (enabled) { + buttonApi.enable(); + } else { + buttonApi.disable(); + } + buttonApi.nodes().toggleClass('action-bar-btn-unavailable', !enabled); +} + function getCancelledColumn(columnIndex) { return { title: 'Cancelled', @@ -1168,11 +1428,13 @@ let casPaymentResponseModal = new abp.ModalManager({ }); function checkEnableHistoryButton(dataTable, history_button) { - if (dataTable.rows({ selected: true }).indexes().length == 1) { + const enabled = dataTable.rows({ selected: true }).indexes().length == 1; + if (enabled) { history_button.enable(); } else { history_button.disable(); } + history_button.nodes().toggleClass('action-bar-btn-unavailable', !enabled); } function openCasResponseModal(casResponse) { diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj index 35a005262d..3c081bf602 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Unity.Payments.Web.csproj @@ -12,17 +12,17 @@ - - + + - - - - + + + + - + @@ -66,8 +66,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml index 5e8eb59e59..b4854c5b38 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.cshtml @@ -1,11 +1,55 @@ @using Unity.Modules.Shared @using Volo.Abp.Authorization.Permissions +@model bool @inject IPermissionChecker PermissionChecker
- + + + @if (Model) + { +
+ + +
+ + }
@@ -14,10 +58,10 @@ }
-
\ No newline at end of file +
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css index 6e5ca5104a..595b912db9 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.css @@ -24,6 +24,10 @@ .dynamic-buttons-div{ display:inline-flex; } + +.action-bar-btn-unavailable { + display: none; +} input[type=search]::-webkit-search-cancel-button { -webkit-appearance: button !important; } \ No newline at end of file diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js index be6bdff0a3..5463e09bee 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/Default.js @@ -76,7 +76,7 @@ $(function () { let groupedValues = Object.values(groupedTags); if (groupedValues.length > 0) { - commonTags = groupedValues.reduce(filterCommonTags); + commonTags = groupedValues.reduce((prev, next) => filterCommonTags(prev, next), groupedValues[0]); } let allTagEntries = Object.entries(groupedTags).map(([paymentId, tagList]) => { @@ -150,20 +150,23 @@ $(function () { manageActionButtons(); }); + // Owns only the standalone TAGS button. The DataTables-driven buttons + // (Check Status, Approve, Decline, Cancel, History) are exclusively + // owned by checkActionButtons() in PaymentRequests/Index.js — touching + // them here would race with that logic, since PubSub.publish() defers + // delivery to a later tick and could re-enable/unhide a button that + // Index.js just disabled for a selected row. function manageActionButtons() { - if (selectedPaymentIds.length == 0) { - $('*[data-selector="batch-payment-table-actions"]').prop('disabled', true); - $('*[data-selector="batch-payment-table-actions"]').addClass('action-bar-btn-unavailable'); - $('.action-bar').addClass('disabled'); - $('#tagPayment').prop('disabled', true); - } - else { - $('*[data-selector="batch-payment-table-actions"]').prop('disabled', false); - $('*[data-selector="batch-payment-table-actions"]').removeClass('action-bar-btn-unavailable'); - $('.action-bar').addClass('active'); - $('#tagPayment').removeClass('disabled'); - $('#tagPayment').prop('disabled', false); - } + const hasSelection = selectedPaymentIds.length > 0; + + $('#tagPayment') + .prop('disabled', !hasSelection) + .toggleClass('action-bar-btn-unavailable', !hasSelection) + .toggleClass('disabled', !hasSelection); + + $('.action-bar') + .toggleClass('active', hasSelection) + .toggleClass('disabled', !hasSelection); } $('#tagPayment').on('click', function () { @@ -192,5 +195,8 @@ $(function () { manageActionButtons(); PubSub.publish("refresh_payment_list"); }); + + // Initialize button states + manageActionButtons(); }); diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs index 1a58e3bcf5..bef6fe1213 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentActionBar/PaymentActionBar.cs @@ -13,9 +13,9 @@ namespace Unity.Payments.Web.Views.Shared.Components.ActionBar AutoInitialize = true)] public class PaymentActionBar : AbpViewComponent { - public IViewComponentResult Invoke() + public IViewComponentResult Invoke(bool showDateRangeFilter = false) { - return View(); + return View(showDateRangeFilter); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js index cd3cbfe767..7e70f1d011 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js +++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js @@ -437,7 +437,14 @@ index: 8, render: function (data) { if (data + '' !== 'undefined' && data?.length > 0) { - return ''; + const escaped = data + .replaceAll('\\', String.raw`\\`) + .replaceAll("'", String.raw`\'`) + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<') + .replaceAll('>', '>'); + return ''; } return null; }, 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 b935efb804..5fae67c71e 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 @@ -1,4 +1,4 @@ - + @@ -13,21 +13,23 @@ - - - - - + + + + + + + + + - - - - + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj index 5ed17173ab..929c1d8534 100644 --- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.TestBase/Unity.Payments.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,46 +9,43 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/docs/FormsIoMappingUsageExamples.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/docs/FormsIoMappingUsageExamples.cs index 6ad1043167..a49fb5ee78 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/docs/FormsIoMappingUsageExamples.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/docs/FormsIoMappingUsageExamples.cs @@ -80,12 +80,10 @@ bool viewExists = await columnsMappingService.ViewExistsAsync("my_form_view"); // Get view data with pagination -var request = new ViewDataRequest +var request = new ViewDataRequest { Skip = 0, - Take = 100, - Filter = "column_name IS NOT NULL", // Optional SQL WHERE clause - OrderBy = "column_name ASC" // Optional SQL ORDER BY clause + Take = 100 }; ViewDataResult data = await columnsMappingService.GetViewDataAsync("my_form_view", request); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs index 6626f00ca8..4a326e30aa 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Configuration/IReportMappingService.cs @@ -101,18 +101,18 @@ public interface IReportMappingService public Task GenerateViewAsync(Guid correlationId, string correlationProvider, string viewName); /// - /// Retrieves paginated and filtered data from a generated database view with support for sorting and custom filtering. + /// Retrieves paginated data from a generated database view. /// /// The name of the database view to query for data. - /// The request parameters containing pagination settings, filtering criteria, and sort ordering. + /// The request parameters containing pagination settings. /// A ViewDataResult containing the queried data rows, total record count, and column information for the requested page. public Task GetViewDataAsync(string viewName, ViewDataRequest request); - + /// /// Retrieves preview data from a generated database view showing only the top record for preview purposes. /// /// The name of the database view to query for preview data. - /// The request parameters for filtering (pagination settings are ignored as only top 1 record is returned). + /// The request parameters (pagination settings are ignored as only top 1 record is returned). /// A ViewDataResult containing the preview data (single top record), count of 1, and column information. public Task GetViewPreviewDataAsync(string viewName, ViewDataRequest request); @@ -132,8 +132,8 @@ public interface IReportMappingService } /// - /// Represents a request for view data with pagination, filtering, and sorting options. - /// Provides flexible data retrieval parameters for querying generated reporting views. + /// Represents a request for view data with pagination options. + /// Provides data retrieval parameters for querying generated reporting views. /// public class ViewDataRequest { @@ -148,18 +148,6 @@ public class ViewDataRequest /// Defaults to 100 to prevent excessive data transfer while allowing reasonable page sizes. /// public int Take { get; set; } = 100; - - /// - /// Gets or sets the SQL WHERE clause filter to apply to the view query. - /// Should be a valid PostgreSQL WHERE clause condition without the "WHERE" keyword. - /// - public string? Filter { get; set; } - - /// - /// Gets or sets the SQL ORDER BY clause to apply for result sorting. - /// Should be a valid PostgreSQL ORDER BY clause without the "ORDER BY" keywords. - /// - public string? OrderBy { get; set; } } /// diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj index c5e4928327..6680bdf353 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application.Contracts/Unity.Reporting.Application.Contracts.csproj @@ -9,14 +9,14 @@ - - + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs index 7524e0cf45..e5a8f62729 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Configuration/ReportMappingService.cs @@ -375,12 +375,12 @@ await backgroundJobManager.EnqueueAsync(new GenerateViewBackgroundJobArgs } /// - /// Retrieves paginated and filtered data from a generated database view with support for sorting and custom filtering. + /// Retrieves paginated data from a generated database view. /// Validates view existence, normalizes the view name, and delegates to the repository for secure data access /// with proper pagination controls to handle large datasets efficiently. /// /// The name of the database view to query for data. - /// The request parameters containing pagination settings (skip/take), filtering criteria, and sort ordering. + /// The request parameters containing pagination settings (skip/take). /// A ViewDataResult containing the queried data rows, total record count, and column information for the requested page. /// /// Thrown when: @@ -411,7 +411,7 @@ public async Task GetViewDataAsync(string viewName, ViewDataRequ /// Validates view existence and normalizes the view name before querying. /// /// The name of the database view to query for preview data. - /// The request parameters for filtering (pagination settings are ignored as only top 1 record is returned). + /// The request parameters (pagination settings are ignored as only top 1 record is returned). /// A ViewDataResult containing the preview data (single top record), count of 1, and column information. /// /// Thrown when: diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/IReportColumnsMapRepository.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/IReportColumnsMapRepository.cs index d401204e6f..693de8ee57 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/IReportColumnsMapRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Domain/Configuration/IReportColumnsMapRepository.cs @@ -45,7 +45,7 @@ public interface IReportColumnsMapRepository : IBasicRepository - /// Retrieves data from a generated view with pagination and filtering. + /// Retrieves data from a generated view with pagination. /// /// The name of the view to query. /// The request parameters for data retrieval. diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs index 08593ddb8a..9c4cd9d1b3 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/EntityFrameworkCore/Repositories/ReportColumnsMapRepository.cs @@ -108,6 +108,15 @@ public async Task GetViewPreviewDataAsync(string viewName, ViewD // Normalize view name to lowercase for consistency var normalizedViewName = viewName.Trim().ToLowerInvariant(); + // SECURITY: Validate the identifier before it is interpolated into SQL below. + // ViewExistsAsync alone is not sufficient - it only proves a matching row exists in + // pg_views, not that the name is free of characters that would break out of the + // quoted identifier it gets embedded in. + if (!IsValidPostgreSqlIdentifier(normalizedViewName)) + { + throw new ArgumentException($"Invalid view name format: {viewName}", nameof(viewName)); + } + var dbContext = await GetDbContextAsync(); var connection = dbContext.Database.GetDbConnection(); await dbContext.Database.OpenConnectionAsync(); @@ -132,18 +141,6 @@ ORDER BY a.""CreationTime"" DESC LIMIT 1 )"; - // Add filtering if provided - if (!string.IsNullOrWhiteSpace(request.Filter)) - { - previewQuery += $" AND ({request.Filter})"; - } - - // Add ordering if provided - if (!string.IsNullOrWhiteSpace(request.OrderBy)) - { - previewQuery += $" ORDER BY {request.OrderBy}"; - } - // Execute the preview query using var dataCommand = connection.CreateCommand(); dataCommand.CommandText = previewQuery; @@ -180,6 +177,15 @@ public async Task GetViewDataAsync(string viewName, ViewDataRequ // Normalize view name to lowercase for consistency var normalizedViewName = viewName.Trim().ToLowerInvariant(); + // SECURITY: Validate the identifier before it is interpolated into SQL below. + // ViewExistsAsync alone is not sufficient - it only proves a matching row exists in + // pg_views, not that the name is free of characters that would break out of the + // quoted identifier it gets embedded in. + if (!IsValidPostgreSqlIdentifier(normalizedViewName)) + { + throw new ArgumentException($"Invalid view name format: {viewName}", nameof(viewName)); + } + var dbContext = await GetDbContextAsync(); var connection = dbContext.Database.GetDbConnection(); await dbContext.Database.OpenConnectionAsync(); @@ -196,14 +202,6 @@ public async Task GetViewDataAsync(string viewName, ViewDataRequ var baseQuery = $@"SELECT * FROM ""Reporting"".""{normalizedViewName}"""; var countQuery = $@"SELECT COUNT(*) FROM ""Reporting"".""{normalizedViewName}"""; - // Add filtering if provided - if (!string.IsNullOrWhiteSpace(request.Filter)) - { - var whereClause = $" WHERE {request.Filter}"; - baseQuery += whereClause; - countQuery += whereClause; - } - // Get total count using (var countCommand = connection.CreateCommand()) { @@ -212,12 +210,6 @@ public async Task GetViewDataAsync(string viewName, ViewDataRequ result.TotalCount = Convert.ToInt32(countResult); } - // Add ordering if provided - if (!string.IsNullOrWhiteSpace(request.OrderBy)) - { - baseQuery += $" ORDER BY {request.OrderBy}"; - } - // Add pagination baseQuery += $" OFFSET {request.Skip} LIMIT {request.Take}"; @@ -316,7 +308,7 @@ public async Task DeleteViewAsync(string viewName) // SECURITY: Use pre-validated identifier in quoted format // The identifier has been validated above, and we use quoted format to prevent injection var sql = $"DROP VIEW IF EXISTS \"Reporting\".\"{normalizedViewName}\""; - await dbContext.Database.ExecuteSqlRawAsync(SafeguardSql(sql)); + await dbContext.Database.ExecuteSqlRawAsync(sql); } finally { @@ -352,7 +344,7 @@ public async Task AssignRoleToViewAsync(string role, string viewName) { // Use ExecuteSqlRaw with properly quoted identifiers - safer than string concatenation var sql = $"GRANT SELECT ON \"Reporting\".\"{normalizedViewName}\" TO \"{role}\""; - await dbContext.Database.ExecuteSqlRawAsync(SafeguardSql(sql)); + await dbContext.Database.ExecuteSqlRawAsync(sql); } finally { @@ -399,8 +391,16 @@ FROM pg_views // Grant SELECT permission on each view to the role foreach (var viewName in viewNames) { + // SECURITY: Validate each identifier read back from pg_views before it is + // interpolated into SQL - quoted PostgreSQL identifiers can contain characters + // (embedded quotes, semicolons) that would otherwise break out of the quotes below. + if (!IsValidPostgreSqlIdentifier(viewName)) + { + throw new ArgumentException($"Invalid view name format: {viewName}", nameof(viewName)); + } + var sql = $"GRANT SELECT ON \"Reporting\".\"{viewName}\" TO \"{role}\""; - await dbContext.Database.ExecuteSqlRawAsync(SafeguardSql(sql)); + await dbContext.Database.ExecuteSqlRawAsync(sql); } } finally @@ -546,57 +546,12 @@ FROM information_schema.views } } - /// - /// Safeguards SQL strings by validating they only contain safe, pre-validated identifiers - /// and preventing SQL injection through strict identifier validation. - /// - /// The SQL string to validate - should only contain pre-validated PostgreSQL identifiers - /// The validated SQL string if safe - /// Thrown if the SQL contains potentially unsafe content - private static string SafeguardSql(string sql) - { - if (string.IsNullOrWhiteSpace(sql)) - { - throw new ArgumentException("SQL cannot be null or empty", nameof(sql)); - } - - // This method is specifically for our controlled scenarios where: - // 1. All identifiers have been pre-validated using IsValidPostgreSqlIdentifier() - // 2. The SQL structure is fixed and known (DROP VIEW, GRANT SELECT) - // 3. Only the identifier names are dynamic (view name, role name) - - // Additional safety check: ensure the SQL only contains expected patterns - // for our specific use cases (DROP VIEW and GRANT SELECT statements) - if (!IsKnownSafeSqlPattern(sql)) - { - throw new ArgumentException("SQL does not match expected safe patterns", nameof(sql)); - } - - return sql; - } - - /// - /// Validates that the SQL string matches one of our known safe patterns - /// - /// The SQL string to validate - /// True if the SQL matches a known safe pattern - private static bool IsKnownSafeSqlPattern(string sql) - { - if (string.IsNullOrWhiteSpace(sql)) - return false; - - // For our specific use cases, we expect either a DROP VIEW or GRANT SELECT statement - // The view name and roles have been pre-validated, so we just check the overall structure here - - return true; - } - /// /// Validates that a string is a valid PostgreSQL identifier to prevent SQL injection /// /// The identifier to validate /// True if the identifier is valid, false otherwise - private static bool IsValidPostgreSqlIdentifier(string identifier) + internal static bool IsValidPostgreSqlIdentifier(string identifier) { if (string.IsNullOrWhiteSpace(identifier)) return false; diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj index d9f72545c4..12e53e814e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Application/Unity.Reporting.Application.csproj @@ -9,13 +9,13 @@ - - - + + + - - - + + + @@ -23,8 +23,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj index aa2aa2c826..2640f8a1fc 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Domain.Shared/Unity.Reporting.Shared.csproj @@ -10,12 +10,12 @@ - - + + - + @@ -24,8 +24,8 @@ - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs index d6e91808e7..0819c74972 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Menus/ReportingMenuContributor.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Permissions; using Volo.Abp.UI.Navigation; @@ -31,16 +32,15 @@ public async Task ConfigureMenuAsync(MenuConfigurationContext context) /// /// The menu configuration context for adding reporting menu items. /// A completed task representing the synchronous menu item addition operations. - private static Task ConfigureReportingMenuAsync(MenuConfigurationContext context) + private static async Task ConfigureReportingMenuAsync(MenuConfigurationContext context) { // Add Reporting Configuration menu item for IT Admin users - context.Menu.AddItem( + await context.AddItemAsync( new ApplicationMenuItem( ReportingMenus.Prefix, displayName: "Reporting", - "~/ReportingAdmin/Configuration", - requiredPermissionName: IdentityConsts.ITAdminPermissionName - )); - return Task.CompletedTask; + "~/ReportingAdmin/Configuration") + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName) + ); } } diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml index 540b18aa36..62e111b10e 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Pages/ReportingAdmin/Configuration/Index.cshtml @@ -44,7 +44,8 @@ value="@tenantRole.ViewRole" data-tenant-id="@tenantRole.TenantId" data-is-default="@tenantRole.IsDefaultInferred" - placeholder="@($"{tenantRole.TenantName.ToLowerInvariant()}_readonly")" /> + placeholder="@($"{tenantRole.TenantName.ToLowerInvariant()}_readonly")" + aria-label="@($"View role for {tenantRole.TenantName}")" /> @if (tenantRole.IsDefaultInferred) { - + - - - - + + + + - + @@ -55,8 +55,8 @@ - - + + all runtime; build; native; contentfiles; analyzers 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 f238989357..063723ecef 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 @@ -168,7 +168,7 @@
- +
/// Retrieves preview data from a generated database view showing sample records for interface display. - /// Fetches sample data from the first application ID found in the view with pagination and filtering support. + /// Fetches sample data from the first application ID found in the view with pagination support. /// Provides preview functionality for users to validate view structure and content before full data access. /// /// The name of the database view to query for preview data. /// The number of records to skip for pagination (defaults to 0). /// The maximum number of records to return (defaults to 100). - /// Optional SQL WHERE clause filter for restricting preview data. - /// Optional SQL ORDER BY clause for sorting preview results. /// An OK result with preview data including sample records and column information, or BadRequest for invalid view names or query parameters. [HttpGet] [Route("GetViewPreviewData")] - public async Task GetViewPreviewData(string viewName, int skip = 0, int take = 100, string? filter = null, string? orderBy = null) + public async Task GetViewPreviewData(string viewName, int skip = 0, int take = 100) { if (!ModelState.IsValid) { @@ -335,9 +333,7 @@ public async Task GetViewPreviewData(string viewName, int skip = var request = new Unity.Reporting.Configuration.ViewDataRequest { Skip = skip, - Take = take, - Filter = filter, - OrderBy = orderBy + Take = take }; var result = await reportMappingService.GetViewPreviewDataAsync(viewName, request); @@ -438,38 +434,6 @@ public class GenerateColumnNamesRequest public Dictionary PathColumns { get; set; } = new Dictionary(); } - /// - /// Request model for view data retrieval operations with pagination, filtering, and sorting capabilities. - /// Provides flexible parameters for querying generated reporting views with proper data access controls - /// and performance optimization through pagination and selective filtering. - /// - public class ViewDataRequest - { - /// - /// Gets or sets the number of records to skip for pagination. - /// Used in combination with Take to implement efficient pagination for large datasets. - /// - public int Skip { get; set; } - - /// - /// Gets or sets the maximum number of records to return in the query result. - /// Provides control over result set size for performance and user interface optimization. - /// - public int Take { get; set; } - - /// - /// Gets or sets the optional SQL WHERE clause filter for restricting query results. - /// Should be a valid PostgreSQL WHERE clause condition without the "WHERE" keyword. - /// - public string? Filter { get; set; } - - /// - /// Gets or sets the optional SQL ORDER BY clause for sorting query results. - /// Should be a valid PostgreSQL ORDER BY clause without the "ORDER BY" keywords. - /// - public string? OrderBy { get; set; } - } - /// /// Request model for report mapping deletion operations with configurable view cleanup behavior. /// Specifies which mapping configuration to delete and whether to remove associated database objects diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs index 2543ae69a0..702c3bbb60 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/ReportingConfigurationViewComponent.cs @@ -114,8 +114,10 @@ public async Task InvokeAsync(Guid formId, Guid? selectedV FormVersions = [.. formVersions .Select(v => new SelectListItem { - Value = v.Id.ToString(), - Text = $"{v.Version} - {v.ChefsFormVersionGuid!.ToString()}" + Value = v.Id.ToString(), + Text = string.IsNullOrEmpty(v.ChefsFormVersionGuid) + ? $"{v.Version}" + : $"{v.Version} - {v.ChefsFormVersionGuid}" })], SelectedVersionId = selectedVersionId, ViewName = viewName, diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/ReportingConfigurationViewStatusController.cs b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/ReportingConfigurationViewStatusController.cs index 03f7d34790..160601b5d2 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/ReportingConfigurationViewStatusController.cs +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/ReportingConfigurationViewStatusController.cs @@ -75,9 +75,7 @@ public async Task PreviewData(Guid versionId, string provider) var request = new ViewDataRequest { Skip = 0, - Take = 100, // This will be ignored by the preview method since it uses LIMIT 1 pattern - OrderBy = null, - Filter = null + Take = 100 // This will be ignored by the preview method since it uses LIMIT 1 pattern }; var viewData = await reportMappingService.GetViewPreviewDataAsync(reportColumnsMap.ViewName, request); diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj index c7c03b191a..401ab64c45 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.Application.Tests/Unity.Reporting.Application.Tests.csproj @@ -1,4 +1,4 @@ - + @@ -11,13 +11,13 @@ - - + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj index 8cd7e3b79e..b3d4492f8b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.Reporting/test/Unity.Reporting.TestBase/Unity.Reporting.TestBase.csproj @@ -1,4 +1,4 @@ - + @@ -9,8 +9,8 @@ - - + + all runtime; build; native; contentfiles; analyzers @@ -18,19 +18,19 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnityAlertConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnityAlertConstants.cs new file mode 100644 index 0000000000..4bdadcd654 --- /dev/null +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Constants/UnityAlertConstants.cs @@ -0,0 +1,13 @@ +using System; + +namespace Unity.Modules.Shared.Constants; + +public static class UnityAlertConstants +{ + // Well-known fixed GUID for the Unity Alert (external Teams notification) Person record (host-level, no tenant) + public static readonly Guid UnityAlertPersonId = new("00000000-0000-0000-0000-000000000003"); + public const string UnityAlertOidcSub = "unity-alert"; + public const string UnityAlertUserName = "UALERT"; + public const string UnityAlertName = "Unity Notifications - External: Unity Team - Grant Management"; + public const string UnityAlertEmail = "7852c6bd.bcgov.onmicrosoft.com@ca.teams.ms"; +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs index 45b2b86846..54a2c1e379 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ChannelProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; @@ -15,16 +16,18 @@ public sealed class PooledChannelProvider( private readonly IConnectionProvider _connectionProvider = connectionProvider ?? throw new ArgumentNullException(nameof(connectionProvider)); private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); private readonly int _maxChannels = maxChannels; - private readonly ConcurrentQueue _channelPool = new(); + private readonly ConcurrentQueue _channelPool = new(); private int _currentChannelCount; private bool _disposed; private const int DefaultMaxChannels = 1000; /// - /// Get a channel from the pool or create a new one if under max limit + /// Get a channel from the pool or create a new one if under max limit. + /// Channels are created with publisher confirmations enabled so producers can + /// rely on awaiting broker confirmation. /// - public IModel? GetChannel() + public async Task GetChannelAsync() { ThrowIfDisposed(); @@ -40,10 +43,11 @@ public sealed class PooledChannelProvider( { try { - var connection = _connectionProvider.GetConnection(); + var connection = await _connectionProvider.GetConnectionAsync(); if (connection != null && connection.IsOpen) { - return connection.CreateModel(); + return await connection.CreateChannelAsync( + new CreateChannelOptions(publisherConfirmationsEnabled: true, publisherConfirmationTrackingEnabled: true)); } _logger.LogWarning("RabbitMQ connection is not open."); @@ -67,7 +71,7 @@ public sealed class PooledChannelProvider( /// /// Return a channel to the pool /// - public void ReturnChannel(IModel channel) + public void ReturnChannel(IChannel channel) { if (_disposed || channel == null) { @@ -82,11 +86,10 @@ public void ReturnChannel(IModel channel) DisposeChannel(channel); } - private void DisposeChannel(IModel channel) + private void DisposeChannel(IChannel channel) { if (channel == null) return; - try { if (channel.IsOpen) channel.Close(); } catch (Exception ex) { _logger.LogWarning(ex, "Error closing channel."); } try { channel.Dispose(); } catch (Exception ex) { _logger.LogWarning(ex, "Error disposing channel."); } Interlocked.Decrement(ref _currentChannelCount); diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs index bebc9a7531..37da8dd4fe 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/ConnectionProvider.cs @@ -1,55 +1,78 @@ - + using Microsoft.Extensions.Logging; using RabbitMQ.Client; using System; +using System.Threading.Tasks; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ { - public sealed class ConnectionProvider : IDisposable, IConnectionProvider + public sealed class ConnectionProvider : IAsyncDisposable, IDisposable, IConnectionProvider { private readonly ILogger _logger; - private readonly IAsyncConnectionFactory _connectionFactory; + private readonly IConnectionFactory _connectionFactory; private IConnection? _connection; - public ConnectionProvider(ILogger logger, IAsyncConnectionFactory connectionFactory) + public ConnectionProvider(ILogger logger, IConnectionFactory connectionFactory) { _logger = logger; _connectionFactory = connectionFactory; } - public void Dispose() + public async ValueTask DisposeAsync() { + if (_connection == null) return; + try { - if (_connection != null && _connection.IsOpen) + if (_connection.IsOpen) { _logger.LogDebug("Closing the connection"); - _connection.Close(); - _connection.Dispose(); + await _connection.CloseAsync(); } } catch (Exception ex) + { + _logger.LogCritical(ex, "Cannot close RabbitMq connection"); + } + finally + { + // Always dispose, even if the connection was already closed or faulted. + await _connection.DisposeAsync(); + } + } + + // Implemented alongside IAsyncDisposable so the DI container can dispose this + // singleton whether it is torn down synchronously or asynchronously. + public void Dispose() + { + try + { + _connection?.Dispose(); + } + catch (Exception ex) { _logger.LogCritical(ex, "Cannot dispose RabbitMq channel or connection"); } } - public IConnection? GetConnection() + public async Task GetConnectionAsync() { if (_connection == null || !_connection.IsOpen) { _logger.LogDebug("Open RabbitMQ connection"); try { - _connection = _connectionFactory.CreateConnection(); - } catch (Exception ex) { + _connection = await _connectionFactory.CreateConnectionAsync(); + } + catch (Exception ex) + { var ExceptionMessage = ex.Message; _logger.LogError(ex, "ConnectionProvider - Exception: {ConnectionProvider}", ExceptionMessage); - } + } } return _connection; } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs index a57ca8acfb..075245012e 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IChannelProvider.cs @@ -1,11 +1,12 @@ -using RabbitMQ.Client; +using RabbitMQ.Client; using System; +using System.Threading.Tasks; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { public interface IChannelProvider : IDisposable { - IModel? GetChannel(); - void ReturnChannel(IModel channel); + Task GetChannelAsync(); + void ReturnChannel(IChannel channel); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs index d447bd328f..ce82cfedd3 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IConnectionProvider.cs @@ -1,9 +1,10 @@ -using RabbitMQ.Client; +using RabbitMQ.Client; +using System.Threading.Tasks; namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { public interface IConnectionProvider { - IConnection? GetConnection(); + Task GetConnectionAsync(); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs index 8b0ca37b55..1baa6ebc8c 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using RabbitMQ.Client; #pragma warning disable CA1005 // Avoid excessive parameters on generic types @@ -13,7 +14,15 @@ public interface IQueueChannelProvider : IDisposable where TQueue /// /// Gets a channel for publishing or consuming messages. /// - IModel GetChannel(); + Task GetChannelAsync(); + + /// + /// Returns a channel obtained from so it can be pooled + /// or disposed and its throttling permit released. Callers that finish with a channel + /// (for example a one-off publish) must return it; long-lived consumer channels are + /// kept open and are not returned until the consumer is torn down. + /// + void ReturnChannel(IChannel channel); } } #pragma warning restore CA1005 // Avoid excessive parameters on generic types diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs index d58d743d7d..e3fd333eec 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueConsumerHandler.cs @@ -1,11 +1,13 @@ -namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { #pragma warning disable S2326 public interface IQueueConsumerHandler where TMessageConsumer : IQueueConsumer where TQueueMessage : class, IQueueMessage { - void RegisterQueueConsumer(); + Task RegisterQueueConsumerAsync(); - void CancelQueueConsumer(); + Task CancelQueueConsumerAsync(); } #pragma warning restore S2326 -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs index d389f2d34f..5aa66197ac 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueProducer.cs @@ -1,7 +1,9 @@ -namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces +using System.Threading.Tasks; + +namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces { public interface IQueueProducer where TQueueMessage : IQueueMessage { - void PublishMessage(TQueueMessage message); + Task PublishMessageAsync(TQueueMessage message); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs index 1eda5b9327..9be537782d 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using RabbitMQ.Client; using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants; @@ -14,14 +15,14 @@ public sealed class PooledQueueChannelProvider : IQueueChannelPro { private readonly IChannelProvider _channelProvider; private readonly ILogger> _logger; - private readonly ConcurrentQueue _channelPool = new(); + private readonly ConcurrentQueue _channelPool = new(); private readonly SemaphoreSlim _channelSemaphore = new(MaxChannels, MaxChannels); private readonly Timer _cleanupTimer; private readonly string _queueName = typeof(TQueueMessage).Name; private volatile bool _disposed; private volatile bool _queueDeclared; - private readonly object _queueDeclareLock = new(); + private readonly SemaphoreSlim _queueDeclareLock = new(1, 1); private const int MaxChannels = 5000; private readonly TimeSpan _channelWaitTimeout = TimeSpan.FromSeconds(10); @@ -37,11 +38,11 @@ public PooledQueueChannelProvider( TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); } - public IModel GetChannel() + public async Task GetChannelAsync() { ObjectDisposedException.ThrowIf(_disposed, nameof(PooledQueueChannelProvider)); - if (!_channelSemaphore.Wait(_channelWaitTimeout)) + if (!await _channelSemaphore.WaitAsync(_channelWaitTimeout)) { throw new TimeoutException( $"Unable to acquire a channel for queue {_queueName} within {_channelWaitTimeout.TotalSeconds} seconds."); @@ -59,8 +60,8 @@ public IModel GetChannel() } // Create new channel - var channel = _channelProvider.GetChannel() ?? throw new InvalidOperationException("Channel cannot be null."); - EnsureQueueDeclared(channel); + var channel = await _channelProvider.GetChannelAsync() ?? throw new InvalidOperationException("Channel cannot be null."); + await EnsureQueueDeclaredAsync(channel); return channel; } catch @@ -70,7 +71,7 @@ public IModel GetChannel() } } - public void ReturnChannel(IModel channel) + public void ReturnChannel(IChannel channel) { if (channel?.IsOpen == true && !_disposed) { @@ -92,28 +93,30 @@ public void ReturnChannel(IModel channel) } } - private void EnsureQueueDeclared(IModel channel) + private async Task EnsureQueueDeclaredAsync(IChannel channel) { if (_queueDeclared) return; - lock (_queueDeclareLock) + await _queueDeclareLock.WaitAsync(); + try { if (_queueDeclared) return; - try - { - DeclareQueue(channel); - _queueDeclared = true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to declare queue {QueueName}", _queueName); - throw new InvalidOperationException($"Failed to declare queue '{_queueName}'. See inner exception for details.", ex); - } + await DeclareQueueAsync(channel); + _queueDeclared = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to declare queue {QueueName}", _queueName); + throw new InvalidOperationException($"Failed to declare queue '{_queueName}'. See inner exception for details.", ex); + } + finally + { + _queueDeclareLock.Release(); } } - private void DeclareQueue(IModel channel) + private async Task DeclareQueueAsync(IChannel channel) { try { @@ -121,24 +124,24 @@ private void DeclareQueue(IModel channel) var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}"; // Ensure DLX exchange exists - channel.ExchangeDeclare(dlxName, ExchangeType.Direct, durable: true); + await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); // Ensure DLQ exists and is bound to DLX - channel.QueueDeclare(dlqName, durable: true, exclusive: false, autoDelete: false, - arguments: new Dictionary + await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDelete: false, + arguments: new Dictionary { { "x-queue-type", "quorum" }, { "x-overflow", "reject-publish" } }); - channel.QueueBind(dlqName, dlxName, dlqName); + await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null); // Declare main queue with DLX args - channel.QueueDeclare( + await channel.QueueDeclareAsync( _queueName, durable: true, exclusive: false, autoDelete: false, - arguments: new Dictionary + arguments: new Dictionary { { "x-queue-type", "quorum" }, { "x-overflow", "reject-publish" }, @@ -148,11 +151,11 @@ private void DeclareQueue(IModel channel) { "x-delivery-limit", 10 } }); - BindToExchange(channel); + await BindToExchangeAsync(channel); } catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) { - if (ex.ShutdownReason.ReplyCode == 406 && + if (ex.ShutdownReason?.ReplyCode == 406 && ex.ShutdownReason.ReplyText.Contains("inequivalent arg")) { _logger.LogWarning( @@ -160,7 +163,7 @@ private void DeclareQueue(IModel channel) "Queue {QueueName} exists with incompatible config. Using existing queue in compatibility mode.", _queueName); - BindToExchange(channel); + await BindToExchangeAsync(channel); } else { @@ -169,20 +172,19 @@ private void DeclareQueue(IModel channel) } } - private void BindToExchange(IModel channel) + private async Task BindToExchangeAsync(IChannel channel) { var mainExchange = $"{_queueName}.exchange"; - channel.ExchangeDeclare(mainExchange, ExchangeType.Direct, durable: true); - channel.QueueBind(_queueName, mainExchange, _queueName); + await channel.ExchangeDeclareAsync(mainExchange, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null); + await channel.QueueBindAsync(_queueName, mainExchange, _queueName, arguments: null); } - private void DisposeChannel(IModel channel) + private void DisposeChannel(IChannel channel) { if (channel == null) return; try { - if (channel.IsOpen) channel.Close(); channel.Dispose(); } catch (Exception ex) @@ -195,7 +197,7 @@ private void CleanupIdleChannels() { if (_disposed) return; - var channels = new List(); + var channels = new List(); while (_channelPool.TryDequeue(out var channel)) channels.Add(channel); @@ -231,8 +233,9 @@ public void Dispose() DisposeChannel(channel); _channelSemaphore.Dispose(); + _queueDeclareLock.Dispose(); } public string QueueName => _queueName; } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs index d64d0a1248..37e07c7048 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -28,21 +28,25 @@ public class QueueConsumerHandler( private string? _consumerTag; private readonly string _consumerName = typeof(TMessageConsumer).Name; - public void RegisterQueueConsumer() + public async Task RegisterQueueConsumerAsync() { _logger.LogInformation("Registering {Consumer} as a consumer for Queue {Queue}", _consumerName, _queueName); using var scope = _serviceProvider.CreateScope(); var channelProvider = scope.ServiceProvider.GetRequiredService>(); - var consumerChannel = channelProvider.GetChannel() ?? throw new QueueingException($"Failed to create consumer channel for {_queueName}"); + var consumerChannel = await channelProvider.GetChannelAsync() ?? throw new QueueingException($"Failed to create consumer channel for {_queueName}"); var consumer = new AsyncEventingBasicConsumer(consumerChannel); - consumer.Received += HandleMessage; + consumer.ReceivedAsync += HandleMessageAsync; try { - _consumerTag = consumerChannel.BasicConsume( + _consumerTag = await consumerChannel.BasicConsumeAsync( queue: _queueName, autoAck: false, + consumerTag: string.Empty, + noLocal: false, + exclusive: false, + arguments: null, consumer: consumer); _logger.LogInformation("Successfully registered {Consumer} as consumer for {Queue}", _consumerName, _queueName); @@ -54,7 +58,7 @@ public void RegisterQueueConsumer() } } - void IQueueConsumerHandler.CancelQueueConsumer() + async Task IQueueConsumerHandler.CancelQueueConsumerAsync() { if (string.IsNullOrEmpty(_consumerTag)) return; @@ -63,25 +67,30 @@ void IQueueConsumerHandler.CancelQueueConsumer( using var scope = _serviceProvider.CreateScope(); var channelProvider = scope.ServiceProvider.GetRequiredService>(); - var channel = channelProvider.GetChannel(); + var channel = await channelProvider.GetChannelAsync(); try { - channel.BasicCancel(_consumerTag); + await channel.BasicCancelAsync(_consumerTag); } catch (Exception ex) { _logger.LogError(ex, "Error canceling consumer {Consumer}", _consumerName); throw new QueueingException($"Error canceling consumer {_consumerName}", ex); } + finally + { + // Return the short-lived cancel channel so it is disposed and its permit released. + channelProvider.ReturnChannel(channel); + } } - private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) + private async Task HandleMessageAsync(object sender, BasicDeliverEventArgs ea) { _logger.LogInformation("Received message on {Queue}", _queueName); using var consumerScope = _serviceProvider.CreateScope(); - var consumingChannel = ((AsyncEventingBasicConsumer)sender).Model; + var consumingChannel = ((AsyncEventingBasicConsumer)sender).Channel; try { @@ -98,7 +107,7 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) else if (tenantedMessage.TenantId == Guid.Empty) { _logger.LogError("Message {MessageId} on {Queue} has an empty TenantId and cannot be processed", message.MessageId, _queueName); - consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); return; } else @@ -106,19 +115,19 @@ private async Task HandleMessage(object sender, BasicDeliverEventArgs ea) await ConsumeWithAuditingAsync(consumerScope, tenantedMessage, message); } - consumingChannel.BasicAck(ea.DeliveryTag, multiple: false); + await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false); _logger.LogInformation("Message {MessageId} successfully processed", message.MessageId); } catch (JsonException jex) { _logger.LogError(jex, "Deserialization failed for message on {Queue}", _queueName); - consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); } catch (Exception ex) { _logger.LogError(ex, "Error processing message on {Queue}", _queueName); - consumingChannel.BasicReject(ea.DeliveryTag, requeue: false); + await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false); } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs index 6e84a42504..3f7d7b1d2d 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerRegistratorService.cs @@ -36,7 +36,7 @@ public async Task StartAsync(CancellationToken cancellationToken) _scope = _serviceProvider.CreateScope(); _consumerHandler = _scope.ServiceProvider.GetRequiredService>(); - _consumerHandler.RegisterQueueConsumer(); + await _consumerHandler.RegisterQueueConsumerAsync(); _logger.LogInformation("Successfully registered consumer {ConsumerName}", typeof(TMessageConsumer).Name); return; @@ -63,7 +63,7 @@ public async Task StartAsync(CancellationToken cancellationToken) } } - public Task StopAsync(CancellationToken cancellationToken) + public async Task StopAsync(CancellationToken cancellationToken) { var QueueServiceName = nameof(QueueConsumerRegistratorService); var ConsumerName = typeof(TMessageConsumer).Name; @@ -73,7 +73,10 @@ public Task StopAsync(CancellationToken cancellationToken) try { - _consumerHandler?.CancelQueueConsumer(); + if (_consumerHandler != null) + { + await _consumerHandler.CancelQueueConsumerAsync(); + } _scope?.Dispose(); } catch (Exception ex) @@ -81,8 +84,6 @@ public Task StopAsync(CancellationToken cancellationToken) var ExceptionMessage = ex.Message; _logger.LogError(ex, "QueueConsumerRegistratorService StopAsync Exception: {ExceptionMessage}", ExceptionMessage); } - - return Task.CompletedTask; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs index 360b71b00b..2af53183cb 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueProducer.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Globalization; using System.Text; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RabbitMQ.Client; @@ -28,7 +29,7 @@ public QueueProducer( _exchangeName = $"{_queueName}.exchange"; } - public void PublishMessage(TQueueMessage message) + public async Task PublishMessageAsync(TQueueMessage message) { if (EqualityComparer.Default.Equals(message, default)) throw new ArgumentNullException(nameof(message)); @@ -36,7 +37,7 @@ public void PublishMessage(TQueueMessage message) if (message.TimeToLive.Ticks <= 0) throw new QueueingException($"{nameof(message.TimeToLive)} cannot be zero or negative"); - var channel = _channelProvider.GetChannel(); + var channel = await _channelProvider.GetChannelAsync(); try { @@ -44,28 +45,24 @@ public void PublishMessage(TQueueMessage message) var serializedMessage = SerializeMessage(message); - var properties = channel.CreateBasicProperties(); - properties.Persistent = true; // quorum queues persist - properties.Type = _queueName; - properties.MessageId = message.MessageId.ToString(); - properties.Expiration = message.TimeToLive.TotalMilliseconds.ToString(CultureInfo.InvariantCulture); - - // Enable publisher confirms once per channel - channel.ConfirmSelect(); + var properties = new BasicProperties + { + Persistent = true, // quorum queues persist + Type = _queueName, + MessageId = message.MessageId.ToString(), + Expiration = message.TimeToLive.TotalMilliseconds.ToString(CultureInfo.InvariantCulture) + }; - channel.BasicPublish( + // Publisher confirmations are enabled on pooled channels, so BasicPublishAsync + // awaits the broker confirmation and throws if the message is not confirmed. + await channel.BasicPublishAsync( exchange: _exchangeName, routingKey: _queueName, + mandatory: false, basicProperties: properties, body: serializedMessage ); - // Wait for confirmation - if (!channel.WaitForConfirms(TimeSpan.FromSeconds(5))) - { - throw new QueueingException($"Publish failed: broker did not confirm message {message.MessageId}"); - } - _logger.LogInformation("Published message {MessageId} to {Queue}", message.MessageId, _queueName); } catch (Exception ex) @@ -73,6 +70,12 @@ public void PublishMessage(TQueueMessage message) _logger.LogError(ex, "PublishMessage Exception: {Message}", ex.Message); throw new QueueingException($"Publish failed: {ex.Message}", ex); } + finally + { + // Return the channel so it is pooled (if still open) or disposed, and its + // throttling permit is released. Without this the channel and permit leak. + _channelProvider.ReturnChannel(channel); + } } private static byte[] SerializeMessage(TQueueMessage message) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs index 2a9190a5fa..f9b82e55a1 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueingStartupExtensions.cs @@ -12,7 +12,7 @@ public static class QueueingStartupExtensions public static void ConfigureRabbitMQ(this IServiceCollection services) { var configuration = services.GetConfiguration(); - services.TryAddSingleton(provider => + services.TryAddSingleton(provider => { var factory = new ConnectionFactory { @@ -21,10 +21,10 @@ public static void ConfigureRabbitMQ(this IServiceCollection services) HostName = configuration.GetValue("RabbitMQ:HostName") ?? "", VirtualHost = configuration.GetValue("RabbitMQ:VirtualHost") ?? "/", Port = configuration.GetValue("RabbitMQ:Port"), - DispatchConsumersAsync = true, AutomaticRecoveryEnabled = true, - // Configure the amount of concurrent consumers within one host - ConsumerDispatchConcurrency = QueueingConstants.MAX_RABBIT_CONCURRENT_CONSUMERS, + // Configure the amount of concurrent consumers within one host. + // Consumers are dispatched asynchronously by default in the v7 client. + ConsumerDispatchConcurrency = (ushort)QueueingConstants.MAX_RABBIT_CONCURRENT_CONSUMERS, }; return factory; }); diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs index 23fb5ad39f..d392a2d0bc 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/RabbitMQConnection.cs @@ -1,4 +1,5 @@ - + +using System.Threading.Tasks; using Microsoft.Extensions.Options; using RabbitMQ.Client; @@ -12,7 +13,7 @@ public RabbitMQConnection(IOptions rabbitMQOptions) { _rabbitMQOptions = rabbitMQOptions; } - public IConnection GetConnection() + public async Task GetConnectionAsync() { var factory = new ConnectionFactory { @@ -22,7 +23,7 @@ public IConnection GetConnection() Password = _rabbitMQOptions.Value.Password, VirtualHost = _rabbitMQOptions.Value.VirtualHost }; - return factory.CreateConnection(); + return await factory.CreateConnectionAsync(); } } -} \ No newline at end of file +} diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs index 4c2a541385..ebf06de147 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Navigation/MenuItemExtensions.cs @@ -1,8 +1,11 @@ +using System; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Unity.Modules.Shared.Specializations; +using Volo.Abp.Authorization.Permissions; using Volo.Abp.Features; using Volo.Abp.UI.Navigation; +using Volo.Abp.Users; namespace Unity.Modules.Shared.Navigation; @@ -12,6 +15,9 @@ public static class MenuItemExtensions private const string OnlyWhenFeaturesKey = "_OnlyWhenFeatures"; private const string ExcludeWhenSpecializationsKey = "_ExcludeWhenSpecializations"; private const string OnlyWhenSpecializationsKey = "_OnlyWhenSpecializations"; + private const string OnlyWhenInRoleKey = "_OnlyWhenInRole"; + private const string RequiredPermissionOrRolePermissionKey = "_RequiredPermissionOrRolePermission"; + private const string RequiredPermissionOrRoleRolesKey = "_RequiredPermissionOrRoleRoles"; /// /// Hides this menu item when any of the given features are enabled. @@ -58,21 +64,87 @@ public static ApplicationMenuItem OnlyWhenSpecializations( } /// - /// Adds the item to the menu, respecting any feature or specialization visibility declarations. + /// Shows this menu item only when the current user is in any of the given roles + /// (checked via ICurrentUser.IsInRole, e.g. Keycloak-issued client roles). + /// + public static ApplicationMenuItem OnlyWhenInRole( + this ApplicationMenuItem item, + params string[] roleNames) + { + item.CustomData[OnlyWhenInRoleKey] = roleNames; + return item; + } + + /// + /// Shows this menu item when the current user either has the given permission granted + /// (checked via IPermissionChecker, e.g. permissions granted through the DB/UI) or is in + /// any of the given roles (checked via ICurrentUser.IsInRole, e.g. Keycloak-issued client + /// roles). + /// + /// + /// Use this instead of the ApplicationMenuItem(..., requiredPermissionName: ...) + /// constructor argument when a role (typically ITAdministrator/ITOperations) should also be + /// able to see the item even without the permission explicitly granted. The constructor arg + /// is checked entirely inside ABP's own menu-rendering pipeline via IPermissionChecker and + /// has no awareness of roles or of authorization policies - it will NOT be satisfied by a + /// role-based RoleOrPermissionRequirement authorization policy registered for the same + /// permission name, even if that policy protects the page itself. + /// (This is exactly what caused the TestingPermissions menu item to stay hidden for + /// ITOperations/ITAdministrator users despite the page being reachable via a + /// RoleOrPermissionRequirement policy of the same name - the menu check and the page's + /// [Authorize] check are two unrelated code paths.) + /// Use instead when the item should be role-gated ONLY, with no + /// permission fallback. + /// + public static ApplicationMenuItem RequirePermissionOrRole( + this ApplicationMenuItem item, + string permissionName, + params string[] roleNames) + { + item.CustomData[RequiredPermissionOrRolePermissionKey] = permissionName; + item.CustomData[RequiredPermissionOrRoleRolesKey] = roleNames; + return item; + } + + /// + /// Adds the item to the menu, respecting any feature, specialization or role visibility declarations. /// public static async Task AddItemAsync( this MenuConfigurationContext context, ApplicationMenuItem item) { - var featureChecker = context.ServiceProvider.GetRequiredService(); - var specializationChecker = context.ServiceProvider.GetRequiredService(); + if (await IsVisibleAsync(item, context.ServiceProvider)) + { + context.Menu.AddItem(item); + } + } + + /// + /// Adds the item as a child of the given parent menu item, respecting any feature, + /// specialization or role visibility declarations. + /// + public static async Task AddItemAsync( + this ApplicationMenuItem parent, + IServiceProvider serviceProvider, + ApplicationMenuItem item) + { + if (await IsVisibleAsync(item, serviceProvider)) + { + parent.AddItem(item); + } + } + + private static async Task IsVisibleAsync(ApplicationMenuItem item, IServiceProvider serviceProvider) + { + var featureChecker = serviceProvider.GetRequiredService(); + var specializationChecker = 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; + return false; } if (item.CustomData.TryGetValue(OnlyWhenFeaturesKey, out var onlyFeatObj) @@ -80,7 +152,7 @@ public static async Task AddItemAsync( { foreach (var feature in onlyFeatures) if (!await featureChecker.IsEnabledAsync(feature)) - return; + return false; } if (item.CustomData.TryGetValue(ExcludeWhenSpecializationsKey, out var excludeSpecObj) @@ -88,7 +160,7 @@ public static async Task AddItemAsync( { foreach (var spec in excludeSpecs) if (await specializationChecker.IsEnabledAsync(spec)) - return; + return false; } if (item.CustomData.TryGetValue(OnlyWhenSpecializationsKey, out var onlySpecObj) @@ -96,9 +168,34 @@ public static async Task AddItemAsync( { foreach (var spec in onlySpecs) if (!await specializationChecker.IsEnabledAsync(spec)) - return; + return false; + } + + if (item.CustomData.TryGetValue(OnlyWhenInRoleKey, out var onlyRoleObj) + && onlyRoleObj is string[] onlyRoles) + { + var currentUser = serviceProvider.GetRequiredService(); + if (!Array.Exists(onlyRoles, currentUser.IsInRole)) + return false; + } + + if (item.CustomData.TryGetValue(RequiredPermissionOrRolePermissionKey, out var permObj) + && permObj is string permissionName) + { + var roles = item.CustomData.TryGetValue(RequiredPermissionOrRoleRolesKey, out var rolesObj) + && rolesObj is string[] requiredRoles + ? requiredRoles + : []; + + var currentUser = serviceProvider.GetRequiredService(); + if (!Array.Exists(roles, currentUser.IsInRole)) + { + var permissionChecker = serviceProvider.GetRequiredService(); + if (!await permissionChecker.IsGrantedAsync(permissionName)) + return false; + } } - context.Menu.AddItem(item); + return true; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs index 1a4b1583e9..a9fc9c2537 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Permissions/IdentityConsts.cs @@ -10,5 +10,9 @@ public static class IdentityConsts public const string ITOperationsRoleName = "ITOperations"; public const string ITOperationsPermissionName = "ITOperations"; + // ITAdministrator is a superset of ITOperations - anywhere ITOperations alone is + // required, ITAdministrator should also be allowed. Use this policy (RequireRole is an + // OR check across the given roles) instead of ITOperationsPolicyName when that's needed. + public const string ITAdminOrITOperationsPolicyName = "ITAdminOrITOperations"; } } diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj index 9a51a4f477..bc3d9950fd 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj @@ -9,22 +9,22 @@ - - + + all runtime; build; native; contentfiles; analyzers - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs index 86466a1d01..b2e6555923 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/DateTimeExtensions.cs @@ -5,8 +5,10 @@ namespace Unity.Modules.Shared.Utils; public static class DateTimeExtensions { - // BC Pacific timezone: PST does NOT observe DST in 2026 — fixed UTC-8 year-round. - private static readonly TimeSpan BcPstOffset = TimeSpan.FromHours(-8); + // BC Pacific timezone: PST/PDT depending on time of year. + private const string WindowsPacificId = "Pacific Standard Time"; + private const string IanaPacificId = "America/Vancouver"; + 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"; @@ -29,13 +31,11 @@ public static string FormatTimestamp(DateTime? utcTime) } /// - /// 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. + /// Converts a given UTC time to BC Pacific Time and formats it as a string. + /// Added support for historic PST rendering. /// /// The UTC time to convert. If , an empty string is returned. - /// A string formatted as "yyyy-MM-dd h:mm tt (PST)". + /// A string formatted as "yyyy-MM-dd h:mm tt (PST)" or "yyyy-MM-dd h:mm tt (PDT)". public static string FormatPacificTime(DateTime? utcTime) { if (!utcTime.HasValue) @@ -45,10 +45,11 @@ public static string FormatPacificTime(DateTime? utcTime) ? utcTime.Value : DateTime.SpecifyKind(utcTime.Value, DateTimeKind.Utc); - // BC PST is a fixed UTC-8 offset — no DST adjustment in 2026. - var bcPstDateTime = new DateTimeOffset(utcTimeValue, TimeSpan.Zero).ToOffset(BcPstOffset); + var pacificTz = PacificTimeZone.Value; + var ptDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcTimeValue, pacificTz); + string abbr = pacificTz.IsDaylightSavingTime(ptDateTime) ? "(PDT)" : "(PST)"; - return $"{bcPstDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} (PST)"; + return $"{ptDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {abbr}"; } /// @@ -74,6 +75,23 @@ public static string FormatMountainTime(DateTime? utcTime) return $"{mtDateTime.ToString("yyyy-MM-dd h:mm tt", CultureInfo.InvariantCulture)} {abbr}"; } + private static TimeZoneInfo GetPacificTimeZone() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (TryFindTimeZone(WindowsPacificId, out var tz)) return tz; + if (TryFindTimeZone(IanaPacificId, out tz)) return tz; + } + else + { + if (TryFindTimeZone(IanaPacificId, out var tz)) return tz; + if (TryFindTimeZone(WindowsPacificId, out tz)) return tz; + } + + throw new TimeZoneNotFoundException( + $"Neither '{WindowsPacificId}' nor '{IanaPacificId}' time zone IDs were found on this system."); + } + private static TimeZoneInfo GetMountainTimeZone() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs index ac60ae4cee..911718d322 100644 --- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs +++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/src/Unity.SharedKernel/Utilities/AbpUserTenantAccessor.cs @@ -18,10 +18,12 @@ public static class AbpUserTenantAccessor var surname = currentUser.SurName; if (!string.IsNullOrWhiteSpace(given) || !string.IsNullOrWhiteSpace(surname)) { - return $"{given} {surname}".Trim(); + var fullName = $"{given} {surname}".Trim(); + if (!string.IsNullOrWhiteSpace(fullName)) return fullName; } - return currentUser.UserName; + var userName = currentUser.UserName; + return string.IsNullOrWhiteSpace(userName) ? null : userName; } public static async Task GetCurrentTenantNameAsync(IServiceProvider serviceProvider) @@ -82,5 +84,11 @@ public static class AbpUserTenantAccessor return null; } + + public static Guid? GetCurrentUserId(IServiceProvider serviceProvider) + { + var currentUser = serviceProvider.GetService(); + return currentUser?.Id; + } } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj index 46ef69fa14..df12318742 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Application.Contracts/Unity.TenantManagement.Application.Contracts.csproj @@ -9,12 +9,12 @@ - - - - - - + + + + + + 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 37aa38e406..b97d7e1b7e 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 @@ -9,17 +9,17 @@ - - - - + + + + - - - - - - + + + + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj index 0be2b80c6a..1015353b47 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi.Client/Unity.TenantManagement.HttpApi.Client.csproj @@ -11,9 +11,9 @@ - - - + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj index 4be4c28fee..18f4d145bb 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.HttpApi/Unity.TenantManagement.HttpApi.csproj @@ -12,12 +12,12 @@ - + - + - - + + 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 54c9e44dea..9915343860 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,18 +1,18 @@ using System.Threading.Tasks; +using Unity.Modules.Shared.Navigation; using Unity.Modules.Shared.Permissions; using Volo.Abp.TenantManagement.Localization; using Volo.Abp.UI.Navigation; -using Volo.Abp.Authorization.Permissions; namespace Unity.TenantManagement.Web.Navigation; public class AbpTenantManagementWebMainMenuContributor : IMenuContributor { - public virtual Task ConfigureMenuAsync(MenuConfigurationContext context) + public virtual async Task ConfigureMenuAsync(MenuConfigurationContext context) { if (context.Menu.Name != StandardMenus.Main) { - return Task.CompletedTask; + return; } var administrationMenu = context.Menu.GetAdministration(); @@ -22,11 +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( + await tenantManagementMenuItem.AddItemAsync( + context.ServiceProvider, new ApplicationMenuItem(TenantManagementMenuNames.Tenants, l["Tenants"], url: "~/TenantManagement/Tenants") - .RequirePermissions(TenantManagementPermissions.Tenants.Default, IdentityConsts.ITOperationsPermissionName) + .OnlyWhenInRole(IdentityConsts.ITAdminRoleName, IdentityConsts.ITOperationsRoleName) ); - - return Task.CompletedTask; } } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.cshtml index 36a2d6ef6f..72fef437f2 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/EndpointManagement/Endpoints/Index.cshtml @@ -27,7 +27,7 @@

Endpoint Management

- +
diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Reconciliation/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Reconciliation/Index.js index 9df2bf0a7f..e38cc30cb5 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Reconciliation/Index.js +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Reconciliation/Index.js @@ -38,8 +38,8 @@ $(function () { let filtered_submissions = submissions.filter(x => x.tenant.toLowerCase().includes($('#ReconciliationTenantFilter').val().toLowerCase()) && - (isNaN(dateTo.getTime()) || new Date(x.createdAt) <= dateTo) && - (isNaN(dateFrom.getTime()) || new Date(x.createdAt) >= dateFrom) && + (Number.isNaN(dateTo.getTime()) || new Date(x.createdAt) <= dateTo) && + (Number.isNaN(dateFrom.getTime()) || new Date(x.createdAt) >= dateFrom) && (x.category == $("#ReconciliationCategoryFilter").val() || $("#ReconciliationCategoryFilter").val() == "all") ); diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/AssignManagerModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/AssignManagerModal.cshtml index c4d7ecc3f4..77cfafd933 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/AssignManagerModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/AssignManagerModal.cshtml @@ -19,8 +19,8 @@ - - + + @foreach (ObjectExtensionPropertyInfo propertyInfo in ObjectExtensionManager.Instance.GetProperties().Where(p => !p.Name.EndsWith("_Text"))) 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 index 3f5c9cd4c2..6966884120 100644 --- 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 @@ -57,7 +57,7 @@ public virtual async Task OnGetAsync(Guid id) .AuthorizeAsync(User, TenantManagementPermissions.Tenants.ManageConnectionStrings)).Succeeded; CanManageFeatures = (await AuthorizationService - .AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded; + .AuthorizeAsync(User, IdentityConsts.ITAdminOrITOperationsPolicyName)).Succeeded; CanManageManagers = CanManageFeatures; @@ -91,7 +91,7 @@ await tenantAppService.AssignManagerAsync(new TenantAssignManagerDto } if (!string.IsNullOrEmpty(FeaturesJson) && - (await AuthorizationService.AuthorizeAsync(User, IdentityConsts.ITOperationsPolicyName)).Succeeded) + (await AuthorizationService.AuthorizeAsync(User, IdentityConsts.ITAdminOrITOperationsPolicyName)).Succeeded) { var featureUpdates = new List(); foreach (var feature in JsonDocument.Parse(FeaturesJson).RootElement.EnumerateArray()) diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml index 5b2b0a4142..1421d24d89 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/EditModal.cshtml @@ -17,16 +17,16 @@ - - + +
- - @foreach (var option in Model.CasClientOptions) 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 3b7b834ed4..105d8d3afe 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 @@ -191,7 +191,7 @@ function _renderFeatureItem(feature) { let id = 'ft-' + feature.name.replaceAll('.', '-'); - let checked = feature.value === 'true' ? ' checked' : ''; + let checked = (feature.value || '').toLowerCase() === 'true' ? ' checked' : ''; return '
' + '' + @@ -270,7 +270,7 @@ 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() }); + features.push({ name: $(this).data('feature-name'), value: $(this).prop('checked') ? 'True' : 'False' }); }); $('#config-features-json').val(JSON.stringify(features)); } diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj index c4e99d3d6c..8b033bd1ce 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Unity.TenantManagement.Web.csproj @@ -28,14 +28,14 @@ - + - + - + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj index 48b9e1753a..c0bb2ab2ec 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.Application.Tests/Unity.TenantManagement.Application.Tests.csproj @@ -1,4 +1,4 @@ - + latest net10.0 @@ -18,12 +18,12 @@ - - - - - - + + + + + + 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 75e6846d56..f63f08c8ef 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 @@ -1,4 +1,4 @@ - + latest net10.0 @@ -13,18 +13,20 @@ - - - - - - + + + + + + + + - - - + + + diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj index 3980e31fd6..378124ba32 100644 --- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj +++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/test/Unity.TenantManagement.TestBase/Unity.TenantManagement.TestBase.csproj @@ -1,4 +1,4 @@ - + latest net10.0 @@ -12,19 +12,19 @@ - - - - - + + + + + - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Menu/_MenuItem.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Menu/_MenuItem.cshtml index 7dae29b077..92997eed0b 100644 --- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Menu/_MenuItem.cshtml +++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Menu/_MenuItem.cshtml @@ -35,7 +35,8 @@ else {