diff --git a/.github/workflows/cleanClosedPR.yml b/.github/workflows/cleanClosedPR.yml index ee87e0b9de..2fd0f1b20d 100644 --- a/.github/workflows/cleanClosedPR.yml +++ b/.github/workflows/cleanClosedPR.yml @@ -40,6 +40,12 @@ jobs: with: persist-credentials: false + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. diff --git a/.github/workflows/cleanMergedPR.yml b/.github/workflows/cleanMergedPR.yml index a629fe1a75..77b0782690 100644 --- a/.github/workflows/cleanMergedPR.yml +++ b/.github/workflows/cleanMergedPR.yml @@ -44,6 +44,12 @@ jobs: with: persist-credentials: false + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. diff --git a/.github/workflows/custom-linter.yml b/.github/workflows/custom-linter.yml new file mode 100644 index 0000000000..b889860e45 --- /dev/null +++ b/.github/workflows/custom-linter.yml @@ -0,0 +1,53 @@ +name: Custom Linter + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + push: + branches: + - dev + +jobs: + # OpenAPI request bodies should contain the property `required` and be set to true + # + # // Valid (request body required) + # + # requestBody: { + # required: true, + # content: {} + # } + # + # // Invalid (request body not required) + # + # requestBody: { + # content: {} + # } + # + openApiRequiredRequestBody: + name: OpenAPI requestBody missing required:true + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Find schemas with optional request bodies + run: | + # For OpenAPI schemas, find all request bodies that don't have `required:true` + # + # Find all non-test files in apr/src/paths + # Find all request bodies that don't contain required:true property + # Print to stdout line num + filename and return exit code + find api/src/paths -type f ! -name '*.test.ts' -exec \ + awk ' + BEGIN {found=0} + /requestBody:/ {found=1} + /required: true/ {found=0} + /content:/ { + if (found) print "line: " NR, FILENAME; + found=0; + } + ' {} \; \ + | grep . && exit 1 || exit 0 + + + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index feed15693d..3c0c0883d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,6 +170,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -232,6 +238,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -293,6 +305,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -349,6 +367,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -409,6 +433,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -463,6 +493,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -485,7 +521,7 @@ jobs: deployDatabaseSetup: name: Deploy Database Setup Image runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 if: ${{ github.event.pull_request.merged == false && github.event.pull_request.draft == false && ( needs.skipDuplicateActions.outputs.ignore_skip == 'true' || @@ -518,6 +554,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -575,6 +617,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. diff --git a/.github/workflows/deployStatic.yml b/.github/workflows/deployStatic.yml index c827de0242..6bb5f6a95b 100644 --- a/.github/workflows/deployStatic.yml +++ b/.github/workflows/deployStatic.yml @@ -43,6 +43,12 @@ jobs: needs: - checkEnv steps: + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -119,6 +125,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -169,6 +181,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -219,6 +237,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -269,6 +293,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -320,6 +350,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -371,6 +407,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -392,7 +434,7 @@ jobs: deployDatabaseSetup: name: Deploy Database Setup Image runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 if: ${{ github.event.pull_request.merged == true }} env: PR_NUMBER: ${{ github.event.number }} @@ -423,6 +465,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -475,6 +523,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. @@ -528,6 +582,12 @@ jobs: if: steps.cache-repo.outputs.cache-hit != 'true' uses: actions/checkout@v4 + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + # Log in to OpenShift. # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. diff --git a/.github/workflows/toDraft.yml b/.github/workflows/toDraft.yml index 530879e24c..f032d9b947 100644 --- a/.github/workflows/toDraft.yml +++ b/.github/workflows/toDraft.yml @@ -1,24 +1,30 @@ -# Switch to Draft -# Scales down running PODS when switching to Draft -name: Draft Scale Down - -on: - pull_request: - types: [converted_to_draft] - -jobs: - scaleDownPods: - name: Scale down the pods for this PR - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - PR_NUMBER: ${{ github.event.number }} - steps: - # Log in to OpenShift. - # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. - # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. - - name: Log in to OpenShift - run: oc login --token=${{ secrets.TOOLS_SA_TOKEN }} --server=https://api.silver.devops.gov.bc.ca:6443 - - - name: Scale down - run: oc get deploymentconfig --namespace af2668-dev --selector env-id=$PR_NUMBER -o name | awk '{print "oc scale --replicas=0 " $1}' | bash +# Switch to Draft +# Scales down running PODS when switching to Draft +name: Draft Scale Down + +on: + pull_request: + types: [converted_to_draft] + +jobs: + scaleDownPods: + name: Scale down the pods for this PR + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + PR_NUMBER: ${{ github.event.number }} + steps: + # Install oc, which was removed from the ubuntu-latest image in v24.04 + - name: Install OpenShift CLI tools + uses: redhat-actions/openshift-tools-installer@v1 + with: + oc: "4.14" + + # Log in to OpenShift. + # Note: The secrets needed to log in are NOT available if the PR comes from a FORK. + # PR's must originate from a branch off the original repo or else all openshift `oc` commands will fail. + - name: Log in to OpenShift + run: oc login --token=${{ secrets.TOOLS_SA_TOKEN }} --server=https://api.silver.devops.gov.bc.ca:6443 + + - name: Scale down + run: oc get deploymentconfig --namespace af2668-dev --selector env-id=$PR_NUMBER -o name | awk '{print "oc scale --replicas=0 " $1}' | bash diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index deda7421d7..82dd6713db 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -121,6 +121,16 @@ parameters: description: S3 key optional prefix required: false value: 'sims' + # Request limits + - name: MAX_REQ_BODY_SIZE + description: Maximum request body size in bytes + value: '52428800' + - name: MAX_UPLOAD_NUM_FILES + description: Maximum number of files uploaded in a single request + value: '10' + - name: MAX_UPLOAD_FILE_SIZE + description: Maximum upload file size in bytes + value: '52428800' # Logging - name: LOG_LEVEL value: 'silent' @@ -162,12 +172,12 @@ parameters: - name: GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE description: gcnotify email template id value: 7779a104-b863-40ac-902f-1aa607d2071a + - name: GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE + description: gcnotify email resubmit template id + value: c973da33-1f2b-435a-9429-d8ab4fd273c5 - name: GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE description: gcnotify sms template id value: af2f1e40-bd72-4612-9c5a-567ee5b26ca5 - - name: GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE - description: gcnotify request resubmit email template - value: c973da33-1f2b-435a-9429-d8ab4fd273c5 - name: GCNOTIFY_EMAIL_URL value: https://api.notification.canada.ca/v2/notifications/email - name: GCNOTIFY_SMS_URL @@ -336,6 +346,13 @@ objects: value: ${KEYCLOAK_API_HOST} - name: KEYCLOAK_API_ENVIRONMENT value: ${KEYCLOAK_API_ENVIRONMENT} + # Request limits + - name: MAX_REQ_BODY_SIZE + value: ${MAX_REQ_BODY_SIZE} + - name: MAX_UPLOAD_NUM_FILES + value: ${MAX_UPLOAD_NUM_FILES} + - name: MAX_UPLOAD_FILE_SIZE + value: ${MAX_UPLOAD_FILE_SIZE} # Object Store (S3) - name: OBJECT_STORE_URL valueFrom: @@ -357,6 +374,8 @@ objects: secretKeyRef: key: object_store_bucket_name name: ${OBJECT_STORE_SECRETS} + - name: S3_KEY_PREFIX + value: ${S3_KEY_PREFIX} # Logging - name: LOG_LEVEL value: ${LOG_LEVEL} @@ -387,6 +406,8 @@ objects: value: ${GCNOTIFY_ADMIN_EMAIL} - name: GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE value: ${GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE} + - name: GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE + value: ${GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE} - name: GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE value: ${GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE} - name: GCNOTIFY_EMAIL_URL diff --git a/api/package-lock.json b/api/package-lock.json index 132e69f877..104e2bf37e 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -95,15 +95,6 @@ "npm": ">= 10.0.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -304,134 +295,67 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.621.0.tgz", - "integrity": "sha512-YhGkd2HQTM4HCYJIAVWvfbUMpOF7XUr1W/e2LN3CFP0WTF4zcCJKesJ2iNHrExqC0Ek1+qarMxiXBK95itfjYQ==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.682.0.tgz", + "integrity": "sha512-gn8yPhOmExhqRENnR/vKvsbTw9jaRPbfNE8fQ2j91ejXhpj632QDNdobY8TxxPm2UEW2ISAVM55r2/UPl0YP1Q==", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.621.0", - "@aws-sdk/client-sts": "3.621.0", - "@aws-sdk/core": "3.621.0", - "@aws-sdk/credential-provider-node": "3.621.0", - "@aws-sdk/middleware-bucket-endpoint": "3.620.0", - "@aws-sdk/middleware-expect-continue": "3.620.0", - "@aws-sdk/middleware-flexible-checksums": "3.620.0", - "@aws-sdk/middleware-host-header": "3.620.0", - "@aws-sdk/middleware-location-constraint": "3.609.0", - "@aws-sdk/middleware-logger": "3.609.0", - "@aws-sdk/middleware-recursion-detection": "3.620.0", - "@aws-sdk/middleware-sdk-s3": "3.621.0", - "@aws-sdk/middleware-signing": "3.620.0", - "@aws-sdk/middleware-ssec": "3.609.0", - "@aws-sdk/middleware-user-agent": "3.620.0", - "@aws-sdk/region-config-resolver": "3.614.0", - "@aws-sdk/signature-v4-multi-region": "3.621.0", - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-endpoints": "3.614.0", - "@aws-sdk/util-user-agent-browser": "3.609.0", - "@aws-sdk/util-user-agent-node": "3.614.0", - "@aws-sdk/xml-builder": "3.609.0", - "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.3.1", - "@smithy/eventstream-serde-browser": "^3.0.5", - "@smithy/eventstream-serde-config-resolver": "^3.0.3", - "@smithy/eventstream-serde-node": "^3.0.4", - "@smithy/fetch-http-handler": "^3.2.4", - "@smithy/hash-blob-browser": "^3.1.2", - "@smithy/hash-node": "^3.0.3", - "@smithy/hash-stream-node": "^3.1.2", - "@smithy/invalid-dependency": "^3.0.3", - "@smithy/md5-js": "^3.0.3", - "@smithy/middleware-content-length": "^3.0.5", - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.13", - "@smithy/middleware-serde": "^3.0.3", - "@smithy/middleware-stack": "^3.0.3", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/node-http-handler": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/url-parser": "^3.0.3", + "@aws-sdk/client-sso-oidc": "3.682.0", + "@aws-sdk/client-sts": "3.682.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-node": "3.682.0", + "@aws-sdk/middleware-bucket-endpoint": "3.679.0", + "@aws-sdk/middleware-expect-continue": "3.679.0", + "@aws-sdk/middleware-flexible-checksums": "3.682.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-location-constraint": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-sdk-s3": "3.682.0", + "@aws-sdk/middleware-ssec": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/signature-v4-multi-region": "3.682.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@aws-sdk/xml-builder": "3.679.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/eventstream-serde-browser": "^3.0.10", + "@smithy/eventstream-serde-config-resolver": "^3.0.7", + "@smithy/eventstream-serde-node": "^3.0.9", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-blob-browser": "^3.1.6", + "@smithy/hash-node": "^3.0.7", + "@smithy/hash-stream-node": "^3.1.6", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/md5-js": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.13", - "@smithy/util-defaults-mode-node": "^3.0.13", - "@smithy/util-endpoints": "^2.0.5", - "@smithy/util-retry": "^3.0.3", - "@smithy/util-stream": "^3.1.3", - "@smithy/util-utf8": "^3.0.0", - "@smithy/util-waiter": "^3.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.621.0.tgz", - "integrity": "sha512-CJrQrtKylcqvyPkRR16JmPZkHroCkWwLErQrg30ZcBPNNok8xbfX6cYqG16XDTnu4lSYzv2Yqc4w4oOBv8xerQ==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-arn-parser": "3.568.0", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/signature-v4": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-stream": "^3.1.3", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.621.0.tgz", - "integrity": "sha512-u+ulCaHFveqHaTxgiYrEAyfBVP6GRKjnmDut67CtjhjslshPWYpo/ndtlCW1zc0RDne3uUeK13Pqp7dp7p1d6g==", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.621.0", - "@aws-sdk/types": "3.609.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/signature-v4": "^4.1.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@smithy/signature-v4": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.1.0.tgz", - "integrity": "sha512-aRryp2XNZeRcOtuJoxjydO6QTaVhxx/vjaR+gx7ZjaFgrgPRyZ3HCTbfwqYj6ZWEBHkCSUfcaymKPURaByukag==", - "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-uri-escape": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", + "@smithy/util-stream": "^3.1.9", "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.6", "tslib": "^2.6.2" }, "engines": { @@ -439,46 +363,46 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.621.0.tgz", - "integrity": "sha512-xpKfikN4u0BaUYZA9FGUMkkDmfoIP0Q03+A86WjqDWhcOoqNA1DkHsE4kZ+r064ifkPUfcNuUvlkVTEoBZoFjA==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.682.0.tgz", + "integrity": "sha512-PYH9RFUMYLFl66HSBq4tIx6fHViMLkhJHTYJoJONpBs+Td+NwVJ895AdLtDsBIhMS0YseCbPpuyjUCJgsUrwUw==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.621.0", - "@aws-sdk/middleware-host-header": "3.620.0", - "@aws-sdk/middleware-logger": "3.609.0", - "@aws-sdk/middleware-recursion-detection": "3.620.0", - "@aws-sdk/middleware-user-agent": "3.620.0", - "@aws-sdk/region-config-resolver": "3.614.0", - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-endpoints": "3.614.0", - "@aws-sdk/util-user-agent-browser": "3.609.0", - "@aws-sdk/util-user-agent-node": "3.614.0", - "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.3.1", - "@smithy/fetch-http-handler": "^3.2.4", - "@smithy/hash-node": "^3.0.3", - "@smithy/invalid-dependency": "^3.0.3", - "@smithy/middleware-content-length": "^3.0.5", - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.13", - "@smithy/middleware-serde": "^3.0.3", - "@smithy/middleware-stack": "^3.0.3", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/node-http-handler": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/url-parser": "^3.0.3", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.13", - "@smithy/util-defaults-mode-node": "^3.0.13", - "@smithy/util-endpoints": "^2.0.5", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-retry": "^3.0.3", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, @@ -487,47 +411,47 @@ } }, "node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.621.0.tgz", - "integrity": "sha512-mMjk3mFUwV2Y68POf1BQMTF+F6qxt5tPu6daEUCNGC9Cenk3h2YXQQoS4/eSyYzuBiYk3vx49VgleRvdvkg8rg==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.682.0.tgz", + "integrity": "sha512-ZPZ7Y/r/w3nx/xpPzGSqSQsB090Xk5aZZOH+WBhTDn/pBEuim09BYXCLzvvxb7R7NnuoQdrTJiwimdJAhHl7ZQ==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.621.0", - "@aws-sdk/credential-provider-node": "3.621.0", - "@aws-sdk/middleware-host-header": "3.620.0", - "@aws-sdk/middleware-logger": "3.609.0", - "@aws-sdk/middleware-recursion-detection": "3.620.0", - "@aws-sdk/middleware-user-agent": "3.620.0", - "@aws-sdk/region-config-resolver": "3.614.0", - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-endpoints": "3.614.0", - "@aws-sdk/util-user-agent-browser": "3.609.0", - "@aws-sdk/util-user-agent-node": "3.614.0", - "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.3.1", - "@smithy/fetch-http-handler": "^3.2.4", - "@smithy/hash-node": "^3.0.3", - "@smithy/invalid-dependency": "^3.0.3", - "@smithy/middleware-content-length": "^3.0.5", - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.13", - "@smithy/middleware-serde": "^3.0.3", - "@smithy/middleware-stack": "^3.0.3", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/node-http-handler": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/url-parser": "^3.0.3", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-node": "3.682.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.13", - "@smithy/util-defaults-mode-node": "^3.0.13", - "@smithy/util-endpoints": "^2.0.5", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-retry": "^3.0.3", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, @@ -535,76 +459,52 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@aws-sdk/client-sts": "^3.621.0" - } - }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" + "@aws-sdk/client-sts": "^3.682.0" } }, "node_modules/@aws-sdk/client-sts": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.621.0.tgz", - "integrity": "sha512-707uiuReSt+nAx6d0c21xLjLm2lxeKc7padxjv92CIrIocnQSlJPxSCM7r5zBhwiahJA6MNQwmTl2xznU67KgA==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.682.0.tgz", + "integrity": "sha512-xKuo4HksZ+F8m9DOfx/ZuWNhaPuqZFPwwy0xqcBT6sWH7OAuBjv/fnpOTzyQhpVTWddlf+ECtMAMrxjxuOExGQ==", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/client-sso-oidc": "3.621.0", - "@aws-sdk/core": "3.621.0", - "@aws-sdk/credential-provider-node": "3.621.0", - "@aws-sdk/middleware-host-header": "3.620.0", - "@aws-sdk/middleware-logger": "3.609.0", - "@aws-sdk/middleware-recursion-detection": "3.620.0", - "@aws-sdk/middleware-user-agent": "3.620.0", - "@aws-sdk/region-config-resolver": "3.614.0", - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-endpoints": "3.614.0", - "@aws-sdk/util-user-agent-browser": "3.609.0", - "@aws-sdk/util-user-agent-node": "3.614.0", - "@smithy/config-resolver": "^3.0.5", - "@smithy/core": "^2.3.1", - "@smithy/fetch-http-handler": "^3.2.4", - "@smithy/hash-node": "^3.0.3", - "@smithy/invalid-dependency": "^3.0.3", - "@smithy/middleware-content-length": "^3.0.5", - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.13", - "@smithy/middleware-serde": "^3.0.3", - "@smithy/middleware-stack": "^3.0.3", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/node-http-handler": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/url-parser": "^3.0.3", + "@aws-sdk/client-sso-oidc": "3.682.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-node": "3.682.0", + "@aws-sdk/middleware-host-header": "3.679.0", + "@aws-sdk/middleware-logger": "3.679.0", + "@aws-sdk/middleware-recursion-detection": "3.679.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/region-config-resolver": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@aws-sdk/util-user-agent-browser": "3.679.0", + "@aws-sdk/util-user-agent-node": "3.682.0", + "@smithy/config-resolver": "^3.0.9", + "@smithy/core": "^2.4.8", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/hash-node": "^3.0.7", + "@smithy/invalid-dependency": "^3.0.7", + "@smithy/middleware-content-length": "^3.0.9", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/middleware-retry": "^3.0.23", + "@smithy/middleware-serde": "^3.0.7", + "@smithy/middleware-stack": "^3.0.7", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/url-parser": "^3.0.7", "@smithy/util-base64": "^3.0.0", "@smithy/util-body-length-browser": "^3.0.0", "@smithy/util-body-length-node": "^3.0.0", - "@smithy/util-defaults-mode-browser": "^3.0.13", - "@smithy/util-defaults-mode-node": "^3.0.13", - "@smithy/util-endpoints": "^2.0.5", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-retry": "^3.0.3", + "@smithy/util-defaults-mode-browser": "^3.0.23", + "@smithy/util-defaults-mode-node": "^3.0.23", + "@smithy/util-endpoints": "^2.1.3", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-retry": "^3.0.7", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, @@ -612,30 +512,20 @@ "node": ">=16.0.0" } }, - "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@aws-sdk/core": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.621.0.tgz", - "integrity": "sha512-CtOwWmDdEiINkGXD93iGfXjN0WmCp9l45cDWHHGa8lRgEDyhuL7bwd/pH5aSzj0j8SiQBG2k0S7DHbd5RaqvbQ==", - "dependencies": { - "@smithy/core": "^2.3.1", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/signature-v4": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/util-middleware": "^3.0.3", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.679.0.tgz", + "integrity": "sha512-CS6PWGX8l4v/xyvX8RtXnBisdCa5+URzKd0L6GvHChype9qKUVxO/Gg6N/y43Hvg7MNWJt9FBPNWIxUB+byJwg==", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/core": "^2.4.8", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/signature-v4": "^4.2.0", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/util-middleware": "^3.0.7", "fast-xml-parser": "4.4.1", "tslib": "^2.6.2" }, @@ -643,44 +533,36 @@ "node": ">=16.0.0" } }, - "node_modules/@aws-sdk/core/node_modules/@smithy/signature-v4": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.1.0.tgz", - "integrity": "sha512-aRryp2XNZeRcOtuJoxjydO6QTaVhxx/vjaR+gx7ZjaFgrgPRyZ3HCTbfwqYj6ZWEBHkCSUfcaymKPURaByukag==", + "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-uri-escape": "^3.0.0", - "@smithy/util-utf8": "^3.0.0", - "tslib": "^2.6.2" + "strnum": "^1.0.5" }, - "engines": { - "node": ">=16.0.0" + "bin": { + "fxparser": "src/cli/cli.js" } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.620.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.620.1.tgz", - "integrity": "sha512-ExuILJ2qLW5ZO+rgkNRj0xiAipKT16Rk77buvPP8csR7kkCflT/gXTyzRe/uzIiETTxM7tr8xuO9MP/DQXqkfg==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.679.0.tgz", + "integrity": "sha512-EdlTYbzMm3G7VUNAMxr9S1nC1qUNqhKlAxFU8E7cKsAe8Bp29CD5HAs3POc56AVo9GC4yRIS+/mtlZSmrckzUA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -688,30 +570,19 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.621.0.tgz", - "integrity": "sha512-/jc2tEsdkT1QQAI5Dvoci50DbSxtJrevemwFsm0B73pwCcOQZ5ZwwSdVqGsPutzYzUVx3bcXg3LRL7jLACqRIg==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/fetch-http-handler": "^3.2.4", - "@smithy/node-http-handler": "^3.1.4", - "@smithy/property-provider": "^3.1.3", - "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/util-stream": "^3.1.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.679.0.tgz", + "integrity": "sha512-ZoKLubW5DqqV1/2a3TSn+9sSKg0T8SsYMt1JeirnuLJF0mCoYFUaWMyvxxKuxPoqvUsaycxKru4GkpJ10ltNBw==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/fetch-http-handler": "^3.2.9", + "@smithy/node-http-handler": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", + "@smithy/util-stream": "^3.1.9", "tslib": "^2.6.2" }, "engines": { @@ -719,69 +590,46 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.621.0.tgz", - "integrity": "sha512-0EWVnSc+JQn5HLnF5Xv405M8n4zfdx9gyGdpnCmAmFqEDHA8LmBdxJdpUk1Ovp/I5oPANhjojxabIW5f1uU0RA==", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.620.1", - "@aws-sdk/credential-provider-http": "3.621.0", - "@aws-sdk/credential-provider-process": "3.620.1", - "@aws-sdk/credential-provider-sso": "3.621.0", - "@aws-sdk/credential-provider-web-identity": "3.621.0", - "@aws-sdk/types": "3.609.0", - "@smithy/credential-provider-imds": "^3.2.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/shared-ini-file-loader": "^3.1.4", - "@smithy/types": "^3.3.0", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.682.0.tgz", + "integrity": "sha512-6eqWeHdK6EegAxqDdiCi215nT3QZPwukgWAYuVxNfJ/5m0/P7fAzF+D5kKVgByUvGJEbq/FEL8Fw7OBe64AA+g==", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/credential-provider-env": "3.679.0", + "@aws-sdk/credential-provider-http": "3.679.0", + "@aws-sdk/credential-provider-process": "3.679.0", + "@aws-sdk/credential-provider-sso": "3.682.0", + "@aws-sdk/credential-provider-web-identity": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { "node": ">=16.0.0" }, "peerDependencies": { - "@aws-sdk/client-sts": "^3.621.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" + "@aws-sdk/client-sts": "^3.682.0" } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.621.0.tgz", - "integrity": "sha512-4JqpccUgz5Snanpt2+53hbOBbJQrSFq7E1sAAbgY6BKVQUsW5qyXqnjvSF32kDeKa5JpBl3bBWLZl04IadcPHw==", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.620.1", - "@aws-sdk/credential-provider-http": "3.621.0", - "@aws-sdk/credential-provider-ini": "3.621.0", - "@aws-sdk/credential-provider-process": "3.620.1", - "@aws-sdk/credential-provider-sso": "3.621.0", - "@aws-sdk/credential-provider-web-identity": "3.621.0", - "@aws-sdk/types": "3.609.0", - "@smithy/credential-provider-imds": "^3.2.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/shared-ini-file-loader": "^3.1.4", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.682.0.tgz", + "integrity": "sha512-HSmDqZcBVZrTctHCT9m++vdlDfJ1ARI218qmZa+TZzzOFNpKWy6QyHMEra45GB9GnkkMmV6unoDSPMuN0AqcMg==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/credential-provider-env": "3.679.0", + "@aws-sdk/credential-provider-http": "3.679.0", + "@aws-sdk/credential-provider-ini": "3.682.0", + "@aws-sdk/credential-provider-process": "3.679.0", + "@aws-sdk/credential-provider-sso": "3.682.0", + "@aws-sdk/credential-provider-web-identity": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/credential-provider-imds": "^3.2.4", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -789,26 +637,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.620.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.620.1.tgz", - "integrity": "sha512-hWqFMidqLAkaV9G460+1at6qa9vySbjQKKc04p59OT7lZ5cO5VH5S4aI05e+m4j364MBROjjk2ugNvfNf/8ILg==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/shared-ini-file-loader": "^3.1.4", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.679.0.tgz", + "integrity": "sha512-u/p4TV8kQ0zJWDdZD4+vdQFTMhkDEJFws040Gm113VHa/Xo1SYOjbpvqeuFoz6VmM0bLvoOWjxB9MxnSQbwKpQ==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -816,28 +653,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.621.0.tgz", - "integrity": "sha512-Kza0jcFeA/GEL6xJlzR2KFf1PfZKMFnxfGzJzl5yN7EjoGdMijl34KaRyVnfRjnCWcsUpBWKNIDk9WZVMY9yiw==", - "dependencies": { - "@aws-sdk/client-sso": "3.621.0", - "@aws-sdk/token-providers": "3.614.0", - "@aws-sdk/types": "3.609.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/shared-ini-file-loader": "^3.1.4", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.682.0.tgz", + "integrity": "sha512-h7IH1VsWgV6YAJSWWV6y8uaRjGqLY3iBpGZlXuTH/c236NMLaNv+WqCBLeBxkFGUb2WeQ+FUPEJDCD69rgLIkg==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/client-sso": "3.682.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/token-providers": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -845,42 +671,31 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.621.0.tgz", - "integrity": "sha512-w7ASSyfNvcx7+bYGep3VBgC3K6vEdLmlpjT7nSIHxxQf+WSdvy+HynwJosrpZax0sK5q0D1Jpn/5q+r5lwwW6w==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.679.0.tgz", + "integrity": "sha512-a74tLccVznXCaBefWPSysUcLXYJiSkeUmQGtalNgJ1vGkE36W5l/8czFiiowdWdKWz7+x6xf0w+Kjkjlj42Ung==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/types": "^3.3.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { "node": ">=16.0.0" }, "peerDependencies": { - "@aws-sdk/client-sts": "^3.621.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" + "@aws-sdk/client-sts": "^3.679.0" } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.621.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.621.0.tgz", - "integrity": "sha512-J4fwwmg2pH+vUsSbGO1kEIbAIv5TqDynrhOy48nIv8U5TNUWP29T+ZLs9+arQDla7bDJmvtB5f3iWHjI775ABQ==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.682.0.tgz", + "integrity": "sha512-MtG/uE5iVpxW+nC+QZTbQhoMWdDNXtAtPaO6vTNvc+q8PWvOnIjY3KmaFpEC1CbHYSFvRdidGL33iONWX18ZYg==", "dependencies": { - "@smithy/abort-controller": "^3.1.1", - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/smithy-client": "^3.1.11", + "@smithy/abort-controller": "^3.1.5", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/smithy-client": "^3.4.0", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", @@ -890,28 +705,19 @@ "node": ">=16.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.621.0" - } - }, - "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", - "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4" + "@aws-sdk/client-s3": "^3.682.0" } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.620.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.620.0.tgz", - "integrity": "sha512-eGLL0W6L3HDb3OACyetZYOWpHJ+gLo0TehQKeQyy2G8vTYXqNTeqYhuI6up9HVjBzU9eQiULVQETmgQs7TFaRg==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-arn-parser": "3.568.0", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.679.0.tgz", + "integrity": "sha512-5EpiPhhGgnF+uJR4DzWUk6Lx3pOn9oM6JGXxeHsiynfoBfq7vHMleq+uABHHSQS+y7XzbyZ7x8tXNQlliMwOsg==", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-arn-parser": "3.679.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", "@smithy/util-config-provider": "^3.0.0", "tslib": "^2.6.2" }, @@ -919,38 +725,14 @@ "node": ">=16.0.0" } }, - "node_modules/@aws-sdk/middleware-bucket-endpoint/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.620.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.620.0.tgz", - "integrity": "sha512-QXeRFMLfyQ31nAHLbiTLtk0oHzG9QLMaof5jIfqcUwnOkO8YnQdeqzakrg1Alpy/VQ7aqzIi8qypkBe2KXZz0A==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.679.0.tgz", + "integrity": "sha512-nYsh9PdWrF4EahTRdXHGlNud82RPc508CNGdh1lAGfPU3tNveGfMBX3PcGBtPOse3p9ebNKRWVmUc9eXSjGvHA==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -958,16 +740,19 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.620.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.620.0.tgz", - "integrity": "sha512-ftz+NW7qka2sVuwnnO1IzBku5ccP+s5qZGeRTPgrKB7OzRW85gthvIo1vQR2w+OwHFk7WJbbhhWwbCbktnP4UA==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.682.0.tgz", + "integrity": "sha512-5u1STth6iZUtAvPDO0NJVYKUX2EYKU7v84MYYaZ3O27HphRjFqDos0keL2KTnHn/KmMD68rM3yiUareWR8hnAQ==", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", - "@aws-sdk/types": "3.609.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", "@smithy/is-array-buffer": "^3.0.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", + "@smithy/util-middleware": "^3.0.7", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, @@ -975,38 +760,14 @@ "node": ">=16.0.0" } }, - "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.620.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.620.0.tgz", - "integrity": "sha512-VMtPEZwqYrII/oUkffYsNWY9PZ9xpNJpMgmyU0rlDQ25O1c0Hk3fJmZRe6pEkAJ0omD7kLrqGl1DUjQVxpd/Rg==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.679.0.tgz", + "integrity": "sha512-y176HuQ8JRY3hGX8rQzHDSbCl9P5Ny9l16z4xmaiLo+Qfte7ee4Yr3yaAKd7GFoJ3/Mhud2XZ37fR015MfYl2w==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1014,24 +775,12 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.609.0.tgz", - "integrity": "sha512-xzsdoTkszGVqGVPjUmgoP7TORiByLueMHieI1fhQL888WPdqctwAx3ES6d/bA9Q/i8jnc6hs+Fjhy8UvBTkE9A==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.679.0.tgz", + "integrity": "sha512-SA1C1D3XgoKTGxyNsOqd016ONpk46xJLWDgJUd00Zb21Ox5wYCoY6aDRKiaMRW+1VfCJdezs1Do3XLyIU9KxyA==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1039,24 +788,12 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.609.0.tgz", - "integrity": "sha512-S62U2dy4jMDhDFDK5gZ4VxFdWzCtLzwbYyFZx2uvPYTECkepLUfzLic2BHg2Qvtu4QjX+oGE3P/7fwaGIsGNuQ==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.679.0.tgz", + "integrity": "sha512-0vet8InEj7nvIvGKk+ch7bEF5SyZ7Us9U7YTEgXPrBNStKeRUsgwRm0ijPWWd0a3oz2okaEwXsFl7G/vI0XiEA==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1064,25 +801,13 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.620.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.620.0.tgz", - "integrity": "sha512-nh91S7aGK3e/o1ck64sA/CyoFw+gAYj2BDOnoNa6ouyCrVJED96ZXWbhye/fz9SgmNUZR2g7GdVpiLpMKZoI5w==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.679.0.tgz", + "integrity": "sha512-sQoAZFsQiW/LL3DfKMYwBoGjYDEnMbA9WslWN8xneCmBAwKo6IcSksvYs23PP8XMIoBGe2I2J9BSr654XWygTQ==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1090,64 +815,22 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.587.0.tgz", - "integrity": "sha512-vtXTGEiw1E9Fax4LmcU2Z208gbrC8ShrdsSLmGcRPpu5NPOGBFBSDG5sy5EDNClrFxIl/Le8coQnD0EDBtx+uQ==", - "dependencies": { - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-arn-parser": "3.568.0", - "@smithy/node-config-provider": "^3.1.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/signature-v4": "^3.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.682.0.tgz", + "integrity": "sha512-Tqndx8elRD4xDR8f5Cng6jpZ/odcm1ZTOtGRFMzHgOCij4BeMf4+/+ecQScobcrAZpUTCUTCzaTvdCdJw8MYJA==", + "dependencies": { + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-arn-parser": "3.679.0", + "@smithy/core": "^2.4.8", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/protocol-http": "^4.1.4", + "@smithy/signature-v4": "^4.2.0", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", "@smithy/util-config-provider": "^3.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-signing": { - "version": "3.620.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-signing/-/middleware-signing-3.620.0.tgz", - "integrity": "sha512-gxI7rubiaanUXaLfJ4NybERa9MGPNg2Ycl/OqANsozrBnR3Pw8vqy3EuVImQOyn2pJ2IFvl8ZPoSMHf4pX56FQ==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/protocol-http": "^4.1.0", - "@smithy/signature-v4": "^4.1.0", - "@smithy/types": "^3.3.0", - "@smithy/util-middleware": "^3.0.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-signing/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-signing/node_modules/@smithy/signature-v4": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.1.0.tgz", - "integrity": "sha512-aRryp2XNZeRcOtuJoxjydO6QTaVhxx/vjaR+gx7ZjaFgrgPRyZ3HCTbfwqYj6ZWEBHkCSUfcaymKPURaByukag==", - "dependencies": { - "@smithy/is-array-buffer": "^3.0.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-uri-escape": "^3.0.0", + "@smithy/util-middleware": "^3.0.7", + "@smithy/util-stream": "^3.1.9", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, @@ -1156,24 +839,12 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.609.0.tgz", - "integrity": "sha512-GZSD1s7+JswWOTamVap79QiDaIV7byJFssBW68GYjyRS5EBjNfwA/8s+6uE6g39R3ojyTbYOmvcANoZEhSULXg==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.679.0.tgz", + "integrity": "sha512-4GNUxXbs1M71uFHRiCAZtN0/g23ogI9YjMe5isAuYMHXwDB3MhqF7usKf954mBP6tplvN44vYlbJ84faaLrTtg==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1181,26 +852,16 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.620.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.620.0.tgz", - "integrity": "sha512-bvS6etn+KsuL32ubY5D3xNof1qkenpbJXf/ugGXbg0n98DvDFQ/F+SMLxHgbnER5dsKYchNnhmtI6/FC3HFu/A==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@aws-sdk/util-endpoints": "3.614.0", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.682.0.tgz", + "integrity": "sha512-7TyvYR9HdGH1/Nq0eeApUTM4izB6rExiw87khVYuJwZHr6FmvIL1FsOVFro/4WlXa0lg4LiYOm/8H8dHv+fXTg==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/core": "3.679.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-endpoints": "3.679.0", + "@smithy/core": "^2.4.8", + "@smithy/protocol-http": "^4.1.4", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1208,27 +869,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.614.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.614.0.tgz", - "integrity": "sha512-vDCeMXvic/LU0KFIUjpC3RiSTIkkvESsEfbVHiHH0YINfl8HnEqR5rj+L8+phsCeVg2+LmYwYxd5NRz4PHxt5g==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.679.0.tgz", + "integrity": "sha512-Ybx54P8Tg6KKq5ck7uwdjiKif7n/8g1x+V0V9uTjBjRWqaIgiqzXwKWoPj6NCNkE7tJNtqI4JrNxp/3S3HvmRw==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/util-middleware": "^3.0.7", "tslib": "^2.6.2" }, "engines": { @@ -1236,17 +885,17 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.590.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.590.0.tgz", - "integrity": "sha512-bb8NEG2IUHqFQJsLzr1nlkTZYyokeo3bGbHwMBKZHbdF+OXrQx0kQUcaDCXYWmeydSfHXxweQEJ2U5i1YEvT/A==", - "dependencies": { - "@aws-sdk/signature-v4-multi-region": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@aws-sdk/util-format-url": "3.577.0", - "@smithy/middleware-endpoint": "^3.0.1", - "@smithy/protocol-http": "^4.0.0", - "@smithy/smithy-client": "^3.1.1", - "@smithy/types": "^3.0.0", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.682.0.tgz", + "integrity": "sha512-2/w8aNYC7BzrfoaeMybk1ZWbQZG8njhTiyPPKcO2BzUX7UzUBxWuoMUP+eEFowVXBYefUmHqm7YZvDX88cOymw==", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.682.0", + "@aws-sdk/types": "3.679.0", + "@aws-sdk/util-format-url": "3.679.0", + "@smithy/middleware-endpoint": "^3.1.4", + "@smithy/protocol-http": "^4.1.4", + "@smithy/smithy-client": "^3.4.0", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1254,15 +903,15 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.587.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.587.0.tgz", - "integrity": "sha512-TR9+ZSjdXvXUz54ayHcCihhcvxI9W7102J1OK6MrLgBlPE7uRhAx42BR9L5lLJ86Xj3LuqPWf//o9d/zR9WVIg==", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.587.0", - "@aws-sdk/types": "3.577.0", - "@smithy/protocol-http": "^4.0.0", - "@smithy/signature-v4": "^3.0.0", - "@smithy/types": "^3.0.0", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.682.0.tgz", + "integrity": "sha512-y7RAQSCb9pH8wCX5We9UXfiqPVwBLLvSljhuXC31mibHmYaZnpNEwHiQlRNQPblyaNpiKnXXQ0H3Ns3FDyDYdQ==", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.682.0", + "@aws-sdk/types": "3.679.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/signature-v4": "^4.2.0", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1270,41 +919,29 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.614.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.614.0.tgz", - "integrity": "sha512-okItqyY6L9IHdxqs+Z116y5/nda7rHxLvROxtAJdLavWTYDydxrZstImNgGWTeVdmc0xX2gJCI77UYUTQWnhRw==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/property-provider": "^3.1.3", - "@smithy/shared-ini-file-loader": "^3.1.4", - "@smithy/types": "^3.3.0", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.679.0.tgz", + "integrity": "sha512-1/+Zso/x2jqgutKixYFQEGli0FELTgah6bm7aB+m2FAWH4Hz7+iMUsazg6nSWm714sG9G3h5u42Dmpvi9X6/hA==", + "dependencies": { + "@aws-sdk/types": "3.679.0", + "@smithy/property-provider": "^3.1.7", + "@smithy/shared-ini-file-loader": "^3.1.8", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { "node": ">=16.0.0" }, "peerDependencies": { - "@aws-sdk/client-sso-oidc": "^3.614.0" - } - }, - "node_modules/@aws-sdk/token-providers/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" + "@aws-sdk/client-sso-oidc": "^3.679.0" } }, "node_modules/@aws-sdk/types": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.577.0.tgz", - "integrity": "sha512-FT2JZES3wBKN/alfmhlo+3ZOq/XJ0C7QOZcDNrpKjB0kqYoKjhVKZ/Hx6ArR0czkKfHzBBEs6y40ebIHx2nSmA==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.679.0.tgz", + "integrity": "sha512-NwVq8YvInxQdJ47+zz4fH3BRRLC6lL+WLkvr242PVBbUOLRyK/lkwHlfiKUoeVIMyK5NF+up6TRg71t/8Bny6Q==", "dependencies": { - "@smithy/types": "^3.0.0", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1312,9 +949,9 @@ } }, "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.568.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.568.0.tgz", - "integrity": "sha512-XUKJWWo+KOB7fbnPP0+g/o5Ulku/X53t7i/h+sPHr5xxYTJJ9CYnbToo95mzxe7xWvkLrsNtJ8L+MnNn9INs2w==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.679.0.tgz", + "integrity": "sha512-CwzEbU8R8rq9bqUFryO50RFBlkfufV9UfMArHPWlo+lmsC+NlSluHQALoj6Jkq3zf5ppn1CN0c1DDLrEqdQUXg==", "dependencies": { "tslib": "^2.6.2" }, @@ -1323,25 +960,13 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.614.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.614.0.tgz", - "integrity": "sha512-wK2cdrXHH4oz4IomV/yrGkftU9A+ITB6nFL+rxxyO78is2ifHJpFdV4aqk4LSkXYPi6CXWNru/Dqc7yiKXgJPw==", - "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/types": "^3.3.0", - "@smithy/util-endpoints": "^2.0.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.679.0.tgz", + "integrity": "sha512-YL6s4Y/1zC45OvddvgE139fjeWSKKPgLlnfrvhVL7alNyY9n7beR4uhoDpNrt5mI6sn9qiBF17790o+xLAXjjg==", "dependencies": { - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", + "@smithy/util-endpoints": "^2.1.3", "tslib": "^2.6.2" }, "engines": { @@ -1349,13 +974,13 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.577.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.577.0.tgz", - "integrity": "sha512-SyEGC2J+y/krFRuPgiF02FmMYhqbiIkOjDE6k4nYLJQRyS6XEAGxZoG+OHeOVEM+bsDgbxokXZiM3XKGu6qFIg==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.679.0.tgz", + "integrity": "sha512-pqV1b/hJ/kumtF8AwObJ7bsGgs/2zuAdZtalSD8Pu4jdjOji3IBwP79giAHyhVwoXaMjkpG3mG4ldn9CVtzZJA==", "dependencies": { - "@aws-sdk/types": "3.577.0", - "@smithy/querystring-builder": "^3.0.0", - "@smithy/types": "^3.0.0", + "@aws-sdk/types": "3.679.0", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1363,9 +988,9 @@ } }, "node_modules/@aws-sdk/util-locate-window": { - "version": "3.568.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.568.0.tgz", - "integrity": "sha512-3nh4TINkXYr+H41QaPelCceEB2FXP3fxp93YZXB/kqJvX0U9j0N0Uk45gvsjmEPzG8XxkPEeLIfT2I1M7A6Lig==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.679.0.tgz", + "integrity": "sha512-zKTd48/ZWrCplkXpYDABI74rQlbR0DNHs8nH95htfSLj9/mWRSwaGptoxwcihaq/77vi/fl2X3y0a1Bo8bt7RA==", "dependencies": { "tslib": "^2.6.2" }, @@ -1374,36 +999,25 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.609.0.tgz", - "integrity": "sha512-fojPU+mNahzQ0YHYBsx0ZIhmMA96H+ZIZ665ObU9tl+SGdbLneVZVikGve+NmHTQwHzwkFsZYYnVKAkreJLAtA==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.679.0.tgz", + "integrity": "sha512-CusSm2bTBG1kFypcsqU8COhnYc6zltobsqs3nRrvYqYaOqtMnuE46K4XTWpnzKgwDejgZGOE+WYyprtAxrPvmQ==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/types": "^3.3.0", + "@aws-sdk/types": "3.679.0", + "@smithy/types": "^3.5.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, - "node_modules/@aws-sdk/util-user-agent-browser/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.614.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.614.0.tgz", - "integrity": "sha512-15ElZT88peoHnq5TEoEtZwoXTXRxNrk60TZNdpl/TUBJ5oNJ9Dqb5Z4ryb8ofN6nm9aFf59GVAerFDz8iUoHBA==", + "version": "3.682.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.682.0.tgz", + "integrity": "sha512-so5s+j0gPoTS0HM4HPL+G0ajk0T6cQAg8JXzRgvyiQAxqie+zGCZAV3VuVeMNWMVbzsgZl0pYZaatPFTLG/AxA==", "dependencies": { - "@aws-sdk/types": "3.609.0", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/types": "^3.3.0", + "@aws-sdk/middleware-user-agent": "3.682.0", + "@aws-sdk/types": "3.679.0", + "@smithy/node-config-provider": "^3.1.8", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1418,24 +1032,12 @@ } } }, - "node_modules/@aws-sdk/util-user-agent-node/node_modules/@aws-sdk/types": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", - "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", - "dependencies": { - "@smithy/types": "^3.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.609.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.609.0.tgz", - "integrity": "sha512-l9XxNcA4HX98rwCC2/KoiWcmEiRfZe4G+mYwDbCFT87JIMj6GBhLDkAzr/W8KAaA2IDr8Vc6J8fZPgVulxxfMA==", + "version": "3.679.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.679.0.tgz", + "integrity": "sha512-nPmhVZb39ty5bcQ7mAwtjezBcsBqTYZ9A2D9v/lE92KCLdu5RhSkPH7O71ZqbZx1mUSg9fAOxHPiG79U5VlpLQ==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.5.0", "tslib": "^2.6.2" }, "engines": { @@ -1443,12 +1045,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "dev": true, "dependencies": { - "@babel/highlight": "^7.24.2", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -1456,30 +1059,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", - "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", + "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", - "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.4", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -1510,29 +1113,30 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", - "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", "dev": true, "dependencies": { - "@babel/types": "^7.24.0", + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -1564,227 +1168,84 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", - "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", - "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", - "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "dev": true, "dependencies": { - "color-convert": "^1.9.0" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { - "node": ">=4" + "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "dev": true, "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { - "node": ">=4" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "dev": true, - "dependencies": { - "color-name": "1.1.3" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "dev": true, "engines": { - "node": ">=0.8.0" + "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "dev": true, "engines": { - "node": ">=4" + "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@babel/helpers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", "dev": true, "dependencies": { - "has-flag": "^3.0.0" + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" }, "engines": { - "node": ">=4" + "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", - "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", "dev": true, + "dependencies": { + "@babel/types": "^7.26.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -1793,33 +1254,30 @@ } }, "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1837,23 +1295,22 @@ } }, "node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "engines": { "node": ">=0.1.90" } @@ -1891,24 +1348,27 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", "dev": true, "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -1994,6 +1454,7 @@ "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", @@ -2043,6 +1504,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", "dev": true }, "node_modules/@isaacs/cliui": { @@ -2062,9 +1524,9 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "engines": { "node": ">=12" }, @@ -2072,38 +1534,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@isaacs/cliui/node_modules/strip-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", @@ -2118,22 +1548,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -2290,9 +1704,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { @@ -2358,47 +1772,47 @@ "type-detect": "4.0.8" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", - "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "node_modules/@sinonjs/commons/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" + "engines": { + "node": ">=4" } }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", - "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "node_modules/@sinonjs/fake-timers": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.3.1.tgz", + "integrity": "sha512-EVJO7nW5M/F5Tur0Rf2z/QoMo+1Ia963RiMtapiQrEWvY0iBUvADo8Beegwjpnle5BHkyHuoxSTW3jF43H1XRA==", "dev": true, "dependencies": { - "@sinonjs/commons": "^2.0.0", - "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" + "@sinonjs/commons": "^3.0.1" } }, - "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", "dev": true, "dependencies": { - "type-detect": "4.0.8" + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" } }, "node_modules/@sinonjs/text-encoding": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", - "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", "dev": true }, "node_modules/@smithy/abort-controller": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.1.tgz", - "integrity": "sha512-MBJBiidoe+0cTFhyxT8g+9g7CeVccLM0IOKKUMCNQ1CNMJ/eIfoo0RTfVrXOONEI1UCN1W+zkiHSbzUNE9dZtQ==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.6.tgz", + "integrity": "sha512-0XuhuHQlEqbNQZp7QxxrFTdVWdwxch4vjxYgfInF91hZFkPxf9QDrdQka0KfxFMPqLNzSw0b95uGTrLliQUavQ==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2406,31 +1820,31 @@ } }, "node_modules/@smithy/chunked-blob-reader": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-3.0.0.tgz", - "integrity": "sha512-sbnURCwjF0gSToGlsBiAmd1lRCmSn72nu9axfJu5lIx6RUEgHu6GwTMbqCdhQSi0Pumcm5vFxsi9XWXb2mTaoA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-4.0.0.tgz", + "integrity": "sha512-jSqRnZvkT4egkq/7b6/QRCNXmmYVcHwnJldqJ3IhVpQE2atObVJ137xmGeuGFhjFUr8gCEVAOKwSY79OvpbDaQ==", "dependencies": { "tslib": "^2.6.2" } }, "node_modules/@smithy/chunked-blob-reader-native": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-3.0.0.tgz", - "integrity": "sha512-VDkpCYW+peSuM4zJip5WDfqvg2Mo/e8yxOv3VF1m11y7B8KKMKVFtmZWDe36Fvk8rGuWrPZHHXZ7rR7uM5yWyg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-3.0.1.tgz", + "integrity": "sha512-VEYtPvh5rs/xlyqpm5NRnfYLZn+q0SRPELbvBV+C/G7IQ+ouTuo+NKKa3ShG5OaFR8NYVMXls9hPYLTvIKKDrQ==", "dependencies": { "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "node_modules/@smithy/config-resolver": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.5.tgz", - "integrity": "sha512-SkW5LxfkSI1bUC74OtfBbdz+grQXYiPYolyu8VfpLIjEoN/sHVBlLeGXMQ1vX4ejkgfv6sxVbQJ32yF2cl1veA==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.10.tgz", + "integrity": "sha512-Uh0Sz9gdUuz538nvkPiyv1DZRX9+D15EKDtnQP5rYVAzM/dnYk3P8cg73jcxyOitPgT3mE3OVj7ky7sibzHWkw==", "dependencies": { - "@smithy/node-config-provider": "^3.1.4", - "@smithy/types": "^3.3.0", + "@smithy/node-config-provider": "^3.1.9", + "@smithy/types": "^3.6.0", "@smithy/util-config-provider": "^3.0.0", - "@smithy/util-middleware": "^3.0.3", + "@smithy/util-middleware": "^3.0.8", "tslib": "^2.6.2" }, "engines": { @@ -2438,17 +1852,17 @@ } }, "node_modules/@smithy/core": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.3.1.tgz", - "integrity": "sha512-BC7VMXx/1BCmRPCVzzn4HGWAtsrb7/0758EtwOGFJQrlSwJBEjCcDLNZLFoL/68JexYa2s+KmgL/UfmXdG6v1w==", - "dependencies": { - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-retry": "^3.0.13", - "@smithy/middleware-serde": "^3.0.3", - "@smithy/protocol-http": "^4.1.0", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/util-middleware": "^3.0.3", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.5.1.tgz", + "integrity": "sha512-DujtuDA7BGEKExJ05W5OdxCoyekcKT3Rhg1ZGeiUWaz2BJIWXjZmsG/DIP4W48GHno7AQwRsaCb8NcBgH3QZpg==", + "dependencies": { + "@smithy/middleware-serde": "^3.0.8", + "@smithy/protocol-http": "^4.1.5", + "@smithy/types": "^3.6.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-middleware": "^3.0.8", + "@smithy/util-stream": "^3.2.1", + "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, "engines": { @@ -2456,14 +1870,14 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.2.0.tgz", - "integrity": "sha512-0SCIzgd8LYZ9EJxUjLXBmEKSZR/P/w6l7Rz/pab9culE/RWuqelAKGJvn5qUOl8BgX8Yj5HWM50A5hiB/RzsgA==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.2.5.tgz", + "integrity": "sha512-4FTQGAsuwqTzVMmiRVTn0RR9GrbRfkP0wfu/tXWVHd2LgNpTY0uglQpIScXK4NaEyXbB3JmZt8gfVqO50lP8wg==", "dependencies": { - "@smithy/node-config-provider": "^3.1.4", - "@smithy/property-provider": "^3.1.3", - "@smithy/types": "^3.3.0", - "@smithy/url-parser": "^3.0.3", + "@smithy/node-config-provider": "^3.1.9", + "@smithy/property-provider": "^3.1.8", + "@smithy/types": "^3.6.0", + "@smithy/url-parser": "^3.0.8", "tslib": "^2.6.2" }, "engines": { @@ -2471,23 +1885,23 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-3.1.2.tgz", - "integrity": "sha512-0mBcu49JWt4MXhrhRAlxASNy0IjDRFU+aWNDRal9OtUJvJNiwDuyKMUONSOjLjSCeGwZaE0wOErdqULer8r7yw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-3.1.7.tgz", + "integrity": "sha512-kVSXScIiRN7q+s1x7BrQtZ1Aa9hvvP9FeCqCdBxv37GimIHgBCOnZ5Ip80HLt0DhnAKpiobFdGqTFgbaJNrazA==", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "@smithy/util-hex-encoding": "^3.0.0", "tslib": "^2.6.2" } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-3.0.5.tgz", - "integrity": "sha512-dEyiUYL/ekDfk+2Ra4GxV+xNnFoCmk1nuIXg+fMChFTrM2uI/1r9AdiTYzPqgb72yIv/NtAj6C3dG//1wwgakQ==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-3.0.11.tgz", + "integrity": "sha512-Pd1Wnq3CQ/v2SxRifDUihvpXzirJYbbtXfEnnLV/z0OGCTx/btVX74P86IgrZkjOydOASBGXdPpupYQI+iO/6A==", "dependencies": { - "@smithy/eventstream-serde-universal": "^3.0.4", - "@smithy/types": "^3.3.0", + "@smithy/eventstream-serde-universal": "^3.0.10", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2495,11 +1909,11 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.0.3.tgz", - "integrity": "sha512-NVTYjOuYpGfrN/VbRQgn31x73KDLfCXCsFdad8DiIc3IcdxL+dYA9zEQPyOP7Fy2QL8CPy2WE4WCUD+ZsLNfaQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.0.8.tgz", + "integrity": "sha512-zkFIG2i1BLbfoGQnf1qEeMqX0h5qAznzaZmMVNnvPZz9J5AWBPkOMckZWPedGUPcVITacwIdQXoPcdIQq5FRcg==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2507,12 +1921,12 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.4.tgz", - "integrity": "sha512-mjlG0OzGAYuUpdUpflfb9zyLrBGgmQmrobNT8b42ZTsGv/J03+t24uhhtVEKG/b2jFtPIHF74Bq+VUtbzEKOKg==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.10.tgz", + "integrity": "sha512-hjpU1tIsJ9qpcoZq9zGHBJPBOeBGYt+n8vfhDwnITPhEre6APrvqq/y3XMDEGUT2cWQ4ramNqBPRbx3qn55rhw==", "dependencies": { - "@smithy/eventstream-serde-universal": "^3.0.4", - "@smithy/types": "^3.3.0", + "@smithy/eventstream-serde-universal": "^3.0.10", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2520,12 +1934,12 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.4.tgz", - "integrity": "sha512-Od9dv8zh3PgOD7Vj4T3HSuox16n0VG8jJIM2gvKASL6aCtcS8CfHZDWe1Ik3ZXW6xBouU+45Q5wgoliWDZiJ0A==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.10.tgz", + "integrity": "sha512-ewG1GHbbqsFZ4asaq40KmxCmXO+AFSM1b+DcO2C03dyJj/ZH71CiTg853FSE/3SHK9q3jiYQIFjlGSwfxQ9kww==", "dependencies": { - "@smithy/eventstream-codec": "^3.1.2", - "@smithy/types": "^3.3.0", + "@smithy/eventstream-codec": "^3.1.7", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2533,34 +1947,34 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.2.4.tgz", - "integrity": "sha512-kBprh5Gs5h7ug4nBWZi1FZthdqSM+T7zMmsZxx0IBvWUn7dK3diz2SHn7Bs4dQGFDk8plDv375gzenDoNwrXjg==", + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.2.9.tgz", + "integrity": "sha512-hYNVQOqhFQ6vOpenifFME546f0GfJn2OiQ3M0FDmuUu8V/Uiwy2wej7ZXxFBNqdx0R5DZAqWM1l6VRhGz8oE6A==", "dependencies": { - "@smithy/protocol-http": "^4.1.0", - "@smithy/querystring-builder": "^3.0.3", - "@smithy/types": "^3.3.0", + "@smithy/protocol-http": "^4.1.4", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", "@smithy/util-base64": "^3.0.0", "tslib": "^2.6.2" } }, "node_modules/@smithy/hash-blob-browser": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-3.1.2.tgz", - "integrity": "sha512-hAbfqN2UbISltakCC2TP0kx4LqXBttEv2MqSPE98gVuDFMf05lU+TpC41QtqGP3Ff5A3GwZMPfKnEy0VmEUpmg==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-3.1.7.tgz", + "integrity": "sha512-4yNlxVNJifPM5ThaA5HKnHkn7JhctFUHvcaz6YXxHlYOSIrzI6VKQPTN8Gs1iN5nqq9iFcwIR9THqchUCouIfg==", "dependencies": { - "@smithy/chunked-blob-reader": "^3.0.0", - "@smithy/chunked-blob-reader-native": "^3.0.0", - "@smithy/types": "^3.3.0", + "@smithy/chunked-blob-reader": "^4.0.0", + "@smithy/chunked-blob-reader-native": "^3.0.1", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" } }, "node_modules/@smithy/hash-node": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.3.tgz", - "integrity": "sha512-2ctBXpPMG+B3BtWSGNnKELJ7SH9e4TNefJS0cd2eSkOOROeBnnVBnAy9LtJ8tY4vUEoe55N4CNPxzbWvR39iBw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.8.tgz", + "integrity": "sha512-tlNQYbfpWXHimHqrvgo14DrMAgUBua/cNoz9fMYcDmYej7MAmUcjav/QKQbFc3NrcPxeJ7QClER4tWZmfwoPng==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "@smithy/util-buffer-from": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" @@ -2570,11 +1984,11 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-3.1.2.tgz", - "integrity": "sha512-PBgDMeEdDzi6JxKwbfBtwQG9eT9cVwsf0dZzLXoJF4sHKHs5HEo/3lJWpn6jibfJwT34I1EBXpBnZE8AxAft6g==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-3.1.7.tgz", + "integrity": "sha512-xMAsvJ3hLG63lsBVi1Hl6BBSfhd8/Qnp8fC06kjOpJvyyCEXdwHITa5Kvdsk6gaAXLhbZMhQMIGvgUbfnJDP6Q==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" }, @@ -2583,11 +1997,11 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.3.tgz", - "integrity": "sha512-ID1eL/zpDULmHJbflb864k72/SNOZCADRc9i7Exq3RUNJw6raWUSlFEQ+3PX3EYs++bTxZB2dE9mEHTQLv61tw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.8.tgz", + "integrity": "sha512-7Qynk6NWtTQhnGTTZwks++nJhQ1O54Mzi7fz4PqZOiYXb4Z1Flpb2yRvdALoggTS8xjtohWUM+RygOtB30YL3Q==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" } }, @@ -2603,22 +2017,22 @@ } }, "node_modules/@smithy/md5-js": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-3.0.3.tgz", - "integrity": "sha512-O/SAkGVwpWmelpj/8yDtsaVe6sINHLB1q8YE/+ZQbDxIw3SRLbTZuRaI10K12sVoENdnHqzPp5i3/H+BcZ3m3Q==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-3.0.8.tgz", + "integrity": "sha512-LwApfTK0OJ/tCyNUXqnWCKoE2b4rDSr4BJlDAVCkiWYeHESr+y+d5zlAanuLW6fnitVJRD/7d9/kN/ZM9Su4mA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" } }, "node_modules/@smithy/middleware-content-length": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.5.tgz", - "integrity": "sha512-ILEzC2eyxx6ncej3zZSwMpB5RJ0zuqH7eMptxC4KN3f+v9bqT8ohssKbhNR78k/2tWW+KS5Spw+tbPF4Ejyqvw==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.10.tgz", + "integrity": "sha512-T4dIdCs1d/+/qMpwhJ1DzOhxCZjZHbHazEPJWdB4GDi2HjIZllVzeBEcdJUN0fomV8DURsgOyrbEUzg3vzTaOg==", "dependencies": { - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", + "@smithy/protocol-http": "^4.1.5", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2626,16 +2040,17 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.1.0.tgz", - "integrity": "sha512-5y5aiKCEwg9TDPB4yFE7H6tYvGFf1OJHNczeY10/EFF8Ir8jZbNntQJxMWNfeQjC1mxPsaQ6mR9cvQbf+0YeMw==", - "dependencies": { - "@smithy/middleware-serde": "^3.0.3", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/shared-ini-file-loader": "^3.1.4", - "@smithy/types": "^3.3.0", - "@smithy/url-parser": "^3.0.3", - "@smithy/util-middleware": "^3.0.3", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.2.1.tgz", + "integrity": "sha512-wWO3xYmFm6WRW8VsEJ5oU6h7aosFXfszlz3Dj176pTij6o21oZnzkCLzShfmRaaCHDkBXWBdO0c4sQAvLFP6zA==", + "dependencies": { + "@smithy/core": "^2.5.1", + "@smithy/middleware-serde": "^3.0.8", + "@smithy/node-config-provider": "^3.1.9", + "@smithy/shared-ini-file-loader": "^3.1.9", + "@smithy/types": "^3.6.0", + "@smithy/url-parser": "^3.0.8", + "@smithy/util-middleware": "^3.0.8", "tslib": "^2.6.2" }, "engines": { @@ -2643,17 +2058,17 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.13.tgz", - "integrity": "sha512-zvCLfaRYCaUmjbF2yxShGZdolSHft7NNCTA28HVN9hKcEbOH+g5irr1X9s+in8EpambclGnevZY4A3lYpvDCFw==", - "dependencies": { - "@smithy/node-config-provider": "^3.1.4", - "@smithy/protocol-http": "^4.1.0", - "@smithy/service-error-classification": "^3.0.3", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", - "@smithy/util-middleware": "^3.0.3", - "@smithy/util-retry": "^3.0.3", + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.25.tgz", + "integrity": "sha512-m1F70cPaMBML4HiTgCw5I+jFNtjgz5z5UdGnUbG37vw6kh4UvizFYjqJGHvicfgKMkDL6mXwyPp5mhZg02g5sg==", + "dependencies": { + "@smithy/node-config-provider": "^3.1.9", + "@smithy/protocol-http": "^4.1.5", + "@smithy/service-error-classification": "^3.0.8", + "@smithy/smithy-client": "^3.4.2", + "@smithy/types": "^3.6.0", + "@smithy/util-middleware": "^3.0.8", + "@smithy/util-retry": "^3.0.8", "tslib": "^2.6.2", "uuid": "^9.0.1" }, @@ -2674,11 +2089,11 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.3.tgz", - "integrity": "sha512-puUbyJQBcg9eSErFXjKNiGILJGtiqmuuNKEYNYfUD57fUl4i9+mfmThtQhvFXU0hCVG0iEJhvQUipUf+/SsFdA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.8.tgz", + "integrity": "sha512-Xg2jK9Wc/1g/MBMP/EUn2DLspN8LNt+GMe7cgF+Ty3vl+Zvu+VeZU5nmhveU+H8pxyTsjrAkci8NqY6OuvZnjA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2686,11 +2101,11 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.3.tgz", - "integrity": "sha512-r4klY9nFudB0r9UdSMaGSyjyQK5adUyPnQN/ZM6M75phTxOdnc/AhpvGD1fQUvgmqjQEBGCwpnPbDm8pH5PapA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.8.tgz", + "integrity": "sha512-d7ZuwvYgp1+3682Nx0MD3D/HtkmZd49N3JUndYWQXfRZrYEnCWYc8BHcNmVsPAp9gKvlurdg/mubE6b/rPS9MA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2698,13 +2113,13 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.4.tgz", - "integrity": "sha512-YvnElQy8HR4vDcAjoy7Xkx9YT8xZP4cBXcbJSgm/kxmiQu08DwUwj8rkGnyoJTpfl/3xYHH+d8zE+eHqoDCSdQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.9.tgz", + "integrity": "sha512-qRHoah49QJ71eemjuS/WhUXB+mpNtwHRWQr77J/m40ewBVVwvo52kYAmb7iuaECgGTTcYxHS4Wmewfwy++ueew==", "dependencies": { - "@smithy/property-provider": "^3.1.3", - "@smithy/shared-ini-file-loader": "^3.1.4", - "@smithy/types": "^3.3.0", + "@smithy/property-provider": "^3.1.8", + "@smithy/shared-ini-file-loader": "^3.1.9", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2712,14 +2127,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.1.4.tgz", - "integrity": "sha512-+UmxgixgOr/yLsUxcEKGH0fMNVteJFGkmRltYFHnBMlogyFdpzn2CwqWmxOrfJELhV34v0WSlaqG1UtE1uXlJg==", - "dependencies": { - "@smithy/abort-controller": "^3.1.1", - "@smithy/protocol-http": "^4.1.0", - "@smithy/querystring-builder": "^3.0.3", - "@smithy/types": "^3.3.0", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.2.5.tgz", + "integrity": "sha512-PkOwPNeKdvX/jCpn0A8n9/TyoxjGZB8WVoJmm9YzsnAgggTj4CrjpRHlTQw7dlLZ320n1mY1y+nTRUDViKi/3w==", + "dependencies": { + "@smithy/abort-controller": "^3.1.6", + "@smithy/protocol-http": "^4.1.5", + "@smithy/querystring-builder": "^3.0.8", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2727,11 +2142,11 @@ } }, "node_modules/@smithy/property-provider": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.3.tgz", - "integrity": "sha512-zahyOVR9Q4PEoguJ/NrFP4O7SMAfYO1HLhB18M+q+Z4KFd4V2obiMnlVoUFzFLSPeVt1POyNWneHHrZaTMoc/g==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.8.tgz", + "integrity": "sha512-ukNUyo6rHmusG64lmkjFeXemwYuKge1BJ8CtpVKmrxQxc6rhUX0vebcptFA9MmrGsnLhwnnqeH83VTU9hwOpjA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2739,11 +2154,11 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.1.0.tgz", - "integrity": "sha512-dPVoHYQ2wcHooGXg3LQisa1hH0e4y0pAddPMeeUPipI1tEOqL6A4N0/G7abeq+K8wrwSgjk4C0wnD1XZpJm5aA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.1.5.tgz", + "integrity": "sha512-hsjtwpIemmCkm3ZV5fd/T0bPIugW1gJXwZ/hpuVubt2hEUApIoUTrf6qIdh9MAWlw0vjMrA1ztJLAwtNaZogvg==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2751,11 +2166,11 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.3.tgz", - "integrity": "sha512-vyWckeUeesFKzCDaRwWLUA1Xym9McaA6XpFfAK5qI9DKJ4M33ooQGqvM4J+LalH4u/Dq9nFiC8U6Qn1qi0+9zw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.8.tgz", + "integrity": "sha512-btYxGVqFUARbUrN6VhL9c3dnSviIwBYD9Rz1jHuN1hgh28Fpv2xjU1HeCeDJX68xctz7r4l1PBnFhGg1WBBPuA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "@smithy/util-uri-escape": "^3.0.0", "tslib": "^2.6.2" }, @@ -2764,11 +2179,11 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.3.tgz", - "integrity": "sha512-zahM1lQv2YjmznnfQsWbYojFe55l0SLG/988brlLv1i8z3dubloLF+75ATRsqPBboUXsW6I9CPGE5rQgLfY0vQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.8.tgz", + "integrity": "sha512-BtEk3FG7Ks64GAbt+JnKqwuobJNX8VmFLBsKIwWr1D60T426fGrV2L3YS5siOcUhhp6/Y6yhBw1PSPxA5p7qGg==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2776,22 +2191,22 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.3.tgz", - "integrity": "sha512-Jn39sSl8cim/VlkLsUhRFq/dKDnRUFlfRkvhOJaUbLBXUsLRLNf9WaxDv/z9BjuQ3A6k/qE8af1lsqcwm7+DaQ==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.8.tgz", + "integrity": "sha512-uEC/kCCFto83bz5ZzapcrgGqHOh/0r69sZ2ZuHlgoD5kYgXJEThCoTuw/y1Ub3cE7aaKdznb+jD9xRPIfIwD7g==", "dependencies": { - "@smithy/types": "^3.3.0" + "@smithy/types": "^3.6.0" }, "engines": { "node": ">=16.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.4.tgz", - "integrity": "sha512-qMxS4hBGB8FY2GQqshcRUy1K6k8aBWP5vwm8qKkCT3A9K2dawUwOIJfqh9Yste/Bl0J2lzosVyrXDj68kLcHXQ==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.9.tgz", + "integrity": "sha512-/+OsJRNtoRbtsX0UpSgWVxFZLsJHo/4sTr+kBg/J78sr7iC+tHeOvOJrS5hCpVQ6sWBbhWLp1UNiuMyZhE6pmA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2799,14 +2214,15 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-3.0.0.tgz", - "integrity": "sha512-kXFOkNX+BQHe2qnLxpMEaCRGap9J6tUGLzc3A9jdn+nD4JdMwCKTJ+zFwQ20GkY+mAXGatyTw3HcoUlR39HwmA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.2.1.tgz", + "integrity": "sha512-NsV1jF4EvmO5wqmaSzlnTVetemBS3FZHdyc5CExbDljcyJCEEkJr8ANu2JvtNbVg/9MvKAWV44kTrGS+Pi4INg==", "dependencies": { "@smithy/is-array-buffer": "^3.0.0", - "@smithy/types": "^3.0.0", + "@smithy/protocol-http": "^4.1.5", + "@smithy/types": "^3.6.0", "@smithy/util-hex-encoding": "^3.0.0", - "@smithy/util-middleware": "^3.0.0", + "@smithy/util-middleware": "^3.0.8", "@smithy/util-uri-escape": "^3.0.0", "@smithy/util-utf8": "^3.0.0", "tslib": "^2.6.2" @@ -2816,15 +2232,16 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.1.11.tgz", - "integrity": "sha512-l0BpyYkciNyMaS+PnFFz4aO5sBcXvGLoJd7mX9xrMBIm2nIQBVvYgp2ZpPDMzwjKCavsXu06iuCm0F6ZJZc6yQ==", - "dependencies": { - "@smithy/middleware-endpoint": "^3.1.0", - "@smithy/middleware-stack": "^3.0.3", - "@smithy/protocol-http": "^4.1.0", - "@smithy/types": "^3.3.0", - "@smithy/util-stream": "^3.1.3", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.4.2.tgz", + "integrity": "sha512-dxw1BDxJiY9/zI3cBqfVrInij6ShjpV4fmGHesGZZUiP9OSE/EVfdwdRz0PgvkEvrZHpsj2htRaHJfftE8giBA==", + "dependencies": { + "@smithy/core": "^2.5.1", + "@smithy/middleware-endpoint": "^3.2.1", + "@smithy/middleware-stack": "^3.0.8", + "@smithy/protocol-http": "^4.1.5", + "@smithy/types": "^3.6.0", + "@smithy/util-stream": "^3.2.1", "tslib": "^2.6.2" }, "engines": { @@ -2832,9 +2249,9 @@ } }, "node_modules/@smithy/types": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.3.0.tgz", - "integrity": "sha512-IxvBBCTFDHbVoK7zIxqA1ZOdc4QfM5HM7rGleCuHi7L1wnKv5Pn69xXJQ9hgxH60ZVygH9/JG0jRgtUncE3QUA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.6.0.tgz", + "integrity": "sha512-8VXK/KzOHefoC65yRgCn5vG1cysPJjHnOVt9d0ybFQSmJgQj152vMn4EkYhGuaOmnnZvCPav/KnYyE6/KsNZ2w==", "dependencies": { "tslib": "^2.6.2" }, @@ -2843,12 +2260,12 @@ } }, "node_modules/@smithy/url-parser": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.3.tgz", - "integrity": "sha512-pw3VtZtX2rg+s6HMs6/+u9+hu6oY6U7IohGhVNnjbgKy86wcIsSZwgHrFR+t67Uyxvp4Xz3p3kGXXIpTNisq8A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.8.tgz", + "integrity": "sha512-4FdOhwpTW7jtSFWm7SpfLGKIBC9ZaTKG5nBF0wK24aoQKQyDIKUw3+KFWCQ9maMzrgTJIuOvOnsV2lLGW5XjTg==", "dependencies": { - "@smithy/querystring-parser": "^3.0.3", - "@smithy/types": "^3.3.0", + "@smithy/querystring-parser": "^3.0.8", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" } }, @@ -2908,13 +2325,13 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.13.tgz", - "integrity": "sha512-ZIRSUsnnMRStOP6OKtW+gCSiVFkwnfQF2xtf32QKAbHR6ACjhbAybDvry+3L5qQYdh3H6+7yD/AiUE45n8mTTw==", + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.25.tgz", + "integrity": "sha512-fRw7zymjIDt6XxIsLwfJfYUfbGoO9CmCJk6rjJ/X5cd20+d2Is7xjU5Kt/AiDt6hX8DAf5dztmfP5O82gR9emA==", "dependencies": { - "@smithy/property-provider": "^3.1.3", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", + "@smithy/property-provider": "^3.1.8", + "@smithy/smithy-client": "^3.4.2", + "@smithy/types": "^3.6.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2923,16 +2340,16 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.13.tgz", - "integrity": "sha512-voUa8TFJGfD+U12tlNNLCDlXibt9vRdNzRX45Onk/WxZe7TS+hTOZouEZRa7oARGicdgeXvt1A0W45qLGYdy+g==", - "dependencies": { - "@smithy/config-resolver": "^3.0.5", - "@smithy/credential-provider-imds": "^3.2.0", - "@smithy/node-config-provider": "^3.1.4", - "@smithy/property-provider": "^3.1.3", - "@smithy/smithy-client": "^3.1.11", - "@smithy/types": "^3.3.0", + "version": "3.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.25.tgz", + "integrity": "sha512-H3BSZdBDiVZGzt8TG51Pd2FvFO0PAx/A0mJ0EH8a13KJ6iUCdYnw/Dk/MdC1kTd0eUuUGisDFaxXVXo4HHFL1g==", + "dependencies": { + "@smithy/config-resolver": "^3.0.10", + "@smithy/credential-provider-imds": "^3.2.5", + "@smithy/node-config-provider": "^3.1.9", + "@smithy/property-provider": "^3.1.8", + "@smithy/smithy-client": "^3.4.2", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2940,12 +2357,12 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.0.5.tgz", - "integrity": "sha512-ReQP0BWihIE68OAblC/WQmDD40Gx+QY1Ez8mTdFMXpmjfxSyz2fVQu3A4zXRfQU9sZXtewk3GmhfOHswvX+eNg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.1.4.tgz", + "integrity": "sha512-kPt8j4emm7rdMWQyL0F89o92q10gvCUa6sBkBtDJ7nV2+P7wpXczzOfoDJ49CKXe5CCqb8dc1W+ZdLlrKzSAnQ==", "dependencies": { - "@smithy/node-config-provider": "^3.1.4", - "@smithy/types": "^3.3.0", + "@smithy/node-config-provider": "^3.1.9", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2964,11 +2381,11 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.3.tgz", - "integrity": "sha512-l+StyYYK/eO3DlVPbU+4Bi06Jjal+PFLSMmlWM1BEwyLxZ3aKkf1ROnoIakfaA7mC6uw3ny7JBkau4Yc+5zfWw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.8.tgz", + "integrity": "sha512-p7iYAPaQjoeM+AKABpYWeDdtwQNxasr4aXQEA/OmbOaug9V0odRVDy3Wx4ci8soljE/JXQo+abV0qZpW8NX0yA==", "dependencies": { - "@smithy/types": "^3.3.0", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2976,12 +2393,12 @@ } }, "node_modules/@smithy/util-retry": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.3.tgz", - "integrity": "sha512-AFw+hjpbtVApzpNDhbjNG5NA3kyoMs7vx0gsgmlJF4s+yz1Zlepde7J58zpIRIsdjc+emhpAITxA88qLkPF26w==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.8.tgz", + "integrity": "sha512-TCEhLnY581YJ+g1x0hapPz13JFqzmh/pMWL2KEFASC51qCfw3+Y47MrTmea4bUE5vsdxQ4F6/KFbUeSz22Q1ow==", "dependencies": { - "@smithy/service-error-classification": "^3.0.3", - "@smithy/types": "^3.3.0", + "@smithy/service-error-classification": "^3.0.8", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -2989,13 +2406,13 @@ } }, "node_modules/@smithy/util-stream": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.1.3.tgz", - "integrity": "sha512-FIv/bRhIlAxC0U7xM1BCnF2aDRPq0UaelqBHkM2lsCp26mcBbgI0tCVTv+jGdsQLUmAMybua/bjDsSu8RQHbmw==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.2.1.tgz", + "integrity": "sha512-R3ufuzJRxSJbE58K9AEnL/uSZyVdHzud9wLS8tIbXclxKzoe09CRohj2xV8wpx5tj7ZbiJaKYcutMm1eYgz/0A==", "dependencies": { - "@smithy/fetch-http-handler": "^3.2.4", - "@smithy/node-http-handler": "^3.1.4", - "@smithy/types": "^3.3.0", + "@smithy/fetch-http-handler": "^4.0.0", + "@smithy/node-http-handler": "^3.2.5", + "@smithy/types": "^3.6.0", "@smithy/util-base64": "^3.0.0", "@smithy/util-buffer-from": "^3.0.0", "@smithy/util-hex-encoding": "^3.0.0", @@ -3006,6 +2423,18 @@ "node": ">=16.0.0" } }, + "node_modules/@smithy/util-stream/node_modules/@smithy/fetch-http-handler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-4.0.0.tgz", + "integrity": "sha512-MLb1f5tbBO2X6K4lMEKJvxeLooyg7guq48C2zKr4qM7F2Gpkz4dc+hdSgu77pCJ76jVqFBjZczHYAs6dp15N+g==", + "dependencies": { + "@smithy/protocol-http": "^4.1.5", + "@smithy/querystring-builder": "^3.0.8", + "@smithy/types": "^3.6.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, "node_modules/@smithy/util-uri-escape": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", @@ -3030,12 +2459,12 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.1.2.tgz", - "integrity": "sha512-4pP0EV3iTsexDx+8PPGAKCQpd/6hsQBaQhqWzU4hqKPHN5epPsxKbvUTIiYIHTxaKt6/kEaqPBpu/ufvfbrRzw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.1.7.tgz", + "integrity": "sha512-d5yGlQtmN/z5eoTtIYgkvOw27US2Ous4VycnXatyoImIF9tzlcpnKqQ/V7qhvJmb2p6xZne1NopCLakdTnkBBQ==", "dependencies": { - "@smithy/abort-controller": "^3.1.1", - "@smithy/types": "^3.3.0", + "@smithy/abort-controller": "^3.1.6", + "@smithy/types": "^3.6.0", "tslib": "^2.6.2" }, "engines": { @@ -3142,9 +2571,9 @@ } }, "node_modules/@types/archiver": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.2.tgz", - "integrity": "sha512-KmROQqbQzKGuaAbmK+ZcytkJ51+YqDa7NmbXjmtC5YBLSyQYo21YaUnQ3HbaPFKL1ooo6RQ6OPYPIDyxfpDDXw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.3.tgz", + "integrity": "sha512-a6wUll6k3zX6qs5KlxIggs1P1JcYJaTCx2gnlr+f0S1yd2DoaEwoIK10HmBaLnZwWneBz+JBm0dwcZu0zECBcQ==", "dev": true, "dependencies": { "@types/readdir-glob": "*" @@ -3160,9 +2589,9 @@ } }, "node_modules/@types/chai": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.14.tgz", - "integrity": "sha512-Wj71sXE4Q4AkGdG9Tvq1u/fquNz9EdG4LIJMwVVII7ashjD/8cf8fyIfJAjRr6YcsXnSE8cOGQPq1gqeR8z+3w==", + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", "dev": true }, "node_modules/@types/clamscan": { @@ -3194,9 +2623,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", - "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -3216,9 +2645,9 @@ "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, "node_modules/@types/http-proxy": { - "version": "1.17.14", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", - "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "version": "1.17.15", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", + "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", "dependencies": { "@types/node": "*" } @@ -3246,9 +2675,9 @@ } }, "node_modules/@types/lodash": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", - "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==", + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", "dev": true }, "node_modules/@types/mime": { @@ -3258,32 +2687,32 @@ "dev": true }, "node_modules/@types/mocha": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.6.tgz", - "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==", + "version": "10.0.9", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.9.tgz", + "integrity": "sha512-sicdRoWtYevwxjOHNMPTl3vSfJM6oyW8o1wXeI7uww6b6xHg8eBznQDNSGBCDJmsE8UMxP05JgZRtsKbTqt//Q==", "dev": true }, "node_modules/@types/multer": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", - "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", "dev": true, "dependencies": { "@types/express": "*" } }, "node_modules/@types/node": { - "version": "18.19.31", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", - "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", + "version": "18.19.61", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.61.tgz", + "integrity": "sha512-z8fH66NcVkDzBItOao+Nyh0fiy7CYdxIyxnNCcZ60aY0I+EA/y4TSi/S/W9i8DIQvwVo7a0pgzAxmDeNnqrpkw==", "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@types/pg": { - "version": "8.11.5", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.5.tgz", - "integrity": "sha512-2xMjVviMxneZHDHX5p5S6tsRRs7TpDHeeK7kTTMe/kAC/mRRNjWHjZg0rkiY+e17jXSZV3zJYDxXV8Cy72/Vuw==", + "version": "8.11.10", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.10.tgz", + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", "dev": true, "dependencies": { "@types/node": "*", @@ -3292,9 +2721,9 @@ } }, "node_modules/@types/qs": { - "version": "6.9.15", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", - "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" + "version": "6.9.16", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", + "integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==" }, "node_modules/@types/range-parser": { "version": "1.2.7", @@ -3366,9 +2795,9 @@ "dev": true }, "node_modules/@types/swagger-ui-express": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz", - "integrity": "sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.7.tgz", + "integrity": "sha512-ovLM9dNincXkzH4YwyYpll75vhzPBlWx6La89wwvYH7mHjVpf0X0K/vR/aUM7SRxmr5tt9z7E5XJcjQ46q+S3g==", "dev": true, "dependencies": { "@types/express": "*", @@ -3594,12 +3023,6 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -3624,9 +3047,9 @@ } }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -3645,10 +3068,13 @@ } }, "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, "engines": { "node": ">=0.4.0" } @@ -3675,14 +3101,14 @@ } }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -3706,9 +3132,9 @@ } }, "node_modules/ansi-colors": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", - "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, "engines": { "node": ">=6" @@ -3770,86 +3196,36 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", - "dependencies": { - "archiver-utils": "^5.0.2", - "async": "^3.2.4", - "buffer-crc32": "^1.0.0", - "readable-stream": "^4.0.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^3.0.0", - "zip-stream": "^6.0.1" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", - "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", - "dependencies": { - "glob": "^10.0.0", - "graceful-fs": "^4.2.0", - "is-stream": "^2.0.1", - "lazystream": "^1.0.0", - "lodash": "^4.17.15", - "normalize-path": "^3.0.0", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/archiver-utils/node_modules/foreground-child": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", - "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">= 14" } }, - "node_modules/archiver-utils/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "engines": { - "node": ">=14" + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">= 14" } }, - "node_modules/archiver/node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" - }, "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", @@ -3947,9 +3323,9 @@ } }, "node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, "node_modules/asynckit": { "version": "0.4.0", @@ -3972,9 +3348,9 @@ } }, "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -3982,9 +3358,9 @@ } }, "node_modules/b4a": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", - "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==" }, "node_modules/balanced-match": { "version": "1.0.2", @@ -3992,9 +3368,9 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/bare-events": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz", - "integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.0.tgz", + "integrity": "sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==", "optional": true }, "node_modules/base64-js": { @@ -4042,9 +3418,9 @@ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -4054,7 +3430,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -4077,20 +3453,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/bowser": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", @@ -4105,11 +3467,11 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4122,9 +3484,9 @@ "dev": true }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "dev": true, "funding": [ { @@ -4141,10 +3503,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -4154,26 +3516,12 @@ } }, "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" } }, "node_modules/buffer-crc32": { @@ -4264,9 +3612,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001610", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001610.tgz", - "integrity": "sha512-QFutAY4NgaelojVMjY63o6XlZyORPaLfyMnsl3HgnWdJUcX6K0oaJymHjH8PT5Gk7sTm8rvC/c5COUQKXqmOMA==", + "version": "1.0.30001675", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001675.tgz", + "integrity": "sha512-/wV1bQwPrkLiQMjaJF5yUMVM/VdRPOCU8QZ+PmG6uW6DvYSrNY1bpwHI/3mOcUosLaJCzYDi5o91IQB51ft6cg==", "dev": true, "funding": [ { @@ -4284,9 +3632,9 @@ ] }, "node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "dependencies": { "assertion-error": "^1.1.0", @@ -4295,7 +3643,7 @@ "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "type-detect": "^4.1.0" }, "engines": { "node": ">=4" @@ -4330,16 +3678,10 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -4352,14 +3694,29 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/clamscan": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.2.1.tgz", - "integrity": "sha512-ureXxucH9MfkhyR4nsJMWPnwq/mKlSYHB5RtkuqWltgSF06kET/C36iAeJuGiGXIWc1bi1FMMoptysHLkIRA/g==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/clamscan/-/clamscan-2.4.0.tgz", + "integrity": "sha512-XBOxUiGOcQGuKmCn5qaM5rIK153fGCwsvJMbjVtcnNJ+j/YHrSj2gKNjyP65yr/E8JsKTTDtKYFG++p7Lzigyw==", "engines": { "node": ">=16.0.0" } @@ -4383,6 +3740,37 @@ "wrap-ansi": "^6.2.0" } }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", @@ -4512,6 +3900,11 @@ "typedarray": "^0.0.6" } }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/concat-stream/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -4565,9 +3958,9 @@ "dev": true }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -4697,9 +4090,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.10", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" }, "node_modules/db-migrate": { "version": "0.11.14", @@ -4766,11 +4159,11 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -4790,9 +4183,9 @@ } }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", "dev": true, "dependencies": { "type-detect": "^4.0.0" @@ -4830,15 +4223,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/default-require-extensions/node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4898,9 +4282,9 @@ } }, "node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", "dev": true, "engines": { "node": ">=0.3.1" @@ -4965,15 +4349,15 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.739", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.739.tgz", - "integrity": "sha512-koRkawXOuN9w/ymhTNxGfB8ta4MRKVW0nzifU17G1UwTWlBg0vv7xnz4nxDnRFSBe9nXMGRgICcAzqXc0PmLeA==", + "version": "1.5.49", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.49.tgz", + "integrity": "sha512-ZXfs1Of8fDb6z7WEYZjXpgIRF6MEu8JdeGA0A40aZq6OQbS+eJpnnV49epZRna2DU/YsEjSQuGtQPPtvt6J65A==", "dev": true }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/enabled": { "version": "2.0.0", @@ -4981,9 +4365,9 @@ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } @@ -5126,9 +4510,9 @@ "dev": true }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "engines": { "node": ">=6" } @@ -5154,6 +4538,7 @@ "version": "8.56.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -5292,18 +4677,6 @@ "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5360,9 +4733,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "dependencies": { "estraverse": "^5.1.0" @@ -5431,36 +4804,36 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", + "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -5499,20 +4872,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/express/node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/eyes": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", @@ -5553,6 +4912,18 @@ "node": ">=8.6.0" } }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-patch": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", @@ -5570,10 +4941,15 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" + }, "node_modules/fast-xml-parser": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", - "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", "funding": [ { "type": "github", @@ -5626,9 +5002,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -5646,12 +5022,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -5743,9 +5119,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -5771,22 +5147,24 @@ } }, "node_modules/foreground-child": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", - "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", - "dev": true, + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", "dependencies": { "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" + "signal-exit": "^4.0.1" }, "engines": { - "node": ">=8.0.0" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -5843,7 +5221,8 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.3", @@ -5969,44 +5348,34 @@ "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==" }, "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">=12" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "dependencies": { - "brace-expansion": "^2.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=10" + "node": ">=10.13.0" } }, "node_modules/globals": { @@ -6025,12 +5394,13 @@ } }, "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "dependencies": { - "define-properties": "^1.1.3" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -6233,9 +5603,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", @@ -6286,9 +5656,9 @@ ] }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" @@ -6346,6 +5716,8 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -6466,11 +5838,14 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6720,9 +6095,10 @@ } }, "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/isexe": { "version": "2.0.0", @@ -6867,9 +6243,9 @@ } }, "node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -6893,15 +6269,15 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "dev": true, "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -7001,9 +6377,9 @@ } }, "node_modules/jwks-rsa/node_modules/@types/jsonwebtoken": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", - "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", + "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", "dependencies": { "@types/node": "*" } @@ -7076,10 +6452,26 @@ } } }, - "node_modules/knex/node_modules/pg-connection-string": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", - "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" + "node_modules/knex/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/knex/node_modules/resolve-from": { "version": "5.0.0", @@ -7105,6 +6497,11 @@ "node": ">= 0.6.3" } }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/lazystream/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -7165,6 +6562,15 @@ "node": ">=4" } }, + "node_modules/load-json-file/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7264,9 +6670,9 @@ } }, "node_modules/logform": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.0.tgz", - "integrity": "sha512-1ulHeNPp6k/LD8H91o7VYFBng5i1BDE7HoKxVbZiGFidS1Rj65qcywLxX+pVfAPoQJEjRdvKcusKwOupHCVOVQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.1.tgz", + "integrity": "sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA==", "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", @@ -7279,14 +6685,6 @@ "node": ">= 12.0.0" } }, - "node_modules/logform/node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/loupe": { "version": "2.3.7", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", @@ -7308,28 +6706,14 @@ } }, "node_modules/lru-memoizer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.2.0.tgz", - "integrity": "sha512-QfOZ6jNkxCcM/BkIPnFsqDhtrazLRsghi9mBwFAzol5GCvj4EkFT899Za3+QwikCg5sRX8JstioBDwOxEyzaNw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", "dependencies": { "lodash.clonedeep": "^4.5.0", - "lru-cache": "~4.0.0" - } - }, - "node_modules/lru-memoizer/node_modules/lru-cache": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz", - "integrity": "sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==", - "dependencies": { - "pseudomap": "^1.0.1", - "yallist": "^2.0.0" + "lru-cache": "6.0.0" } }, - "node_modules/lru-memoizer/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==" - }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -7378,9 +6762,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge2": { "version": "1.4.1", @@ -7400,11 +6787,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -7442,9 +6829,9 @@ } }, "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7483,31 +6870,31 @@ } }, "node_modules/mocha": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.4.0.tgz", - "integrity": "sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA==", + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", "dev": true, "dependencies": { - "ansi-colors": "4.1.1", - "browser-stdout": "1.3.1", - "chokidar": "3.5.3", - "debug": "4.3.4", - "diff": "5.0.0", - "escape-string-regexp": "4.0.0", - "find-up": "5.0.0", - "glob": "8.1.0", - "he": "1.2.0", - "js-yaml": "4.1.0", - "log-symbols": "4.1.0", - "minimatch": "5.0.1", - "ms": "2.1.3", - "serialize-javascript": "6.0.0", - "strip-json-comments": "3.1.1", - "supports-color": "8.1.1", - "workerpool": "6.2.1", - "yargs": "16.2.0", - "yargs-parser": "20.2.4", - "yargs-unparser": "2.0.0" + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" }, "bin": { "_mocha": "bin/_mocha", @@ -7528,10 +6915,36 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/mocha/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mocha/node_modules/minimatch": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", - "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -7540,11 +6953,19 @@ "node": ">=10" } }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "node_modules/mocha/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", @@ -7622,9 +7043,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/multer": { "version": "1.4.5-lts.1", @@ -7649,9 +7070,9 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, "node_modules/nan": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", - "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", "optional": true }, "node_modules/natural-compare": { @@ -7688,9 +7109,9 @@ } }, "node_modules/nise/node_modules/path-to-regexp": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.2.tgz", - "integrity": "sha512-GQX3SSMokngb36+whdpRXE+3f9V8UzyAorlYvOGx87ufGHehNTn5lCxrKtLyZ4Yl/wEKnNnr98ZzOwwDZV5ogw==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true }, "node_modules/node-fs": { @@ -7722,15 +7143,15 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, "node_modules/nodemon": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", - "integrity": "sha512-xqlktYlDMCepBJd43ZQhjWwMw2obW/JRvkrLxq5RCNcuDDX1DbcPT+qT1IlIIdf+DhnWs90JpTMe+Y5KxOchvA==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", + "integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", "dev": true, "dependencies": { "chokidar": "^3.5.2", @@ -7798,21 +7219,6 @@ "node": ">=4" } }, - "node_modules/nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", - "dev": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -8091,10 +7497,24 @@ "node": ">=8" } }, + "node_modules/nyc/node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nyc/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -8171,6 +7591,12 @@ "node": ">=8" } }, + "node_modules/nyc/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8188,9 +7614,12 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8243,6 +7672,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "dependencies": { "wrappy": "1" } @@ -8367,17 +7797,17 @@ "integrity": "sha512-/Yvsd2D7miYB4HLJ3hOOS0+vnowQpaT75FsHzr/y5M9P4q9bwa7RcbW2YdH6KZBn8ceLbKGnHxMZ1CHliGHUFw==" }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -8449,9 +7879,9 @@ } }, "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, "node_modules/parent-module": { "version": "1.0.1", @@ -8548,9 +7978,9 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" }, "node_modules/path-type": { "version": "4.0.0", @@ -8571,13 +8001,13 @@ } }, "node_modules/pg": { - "version": "8.11.5", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.5.tgz", - "integrity": "sha512-jqgNHSKL5cbDjFlHyYsCXmQDrfIX/3RsNwYqpd4N0Kt8niLuNoRNH+aazv6cOd43gPh9Y4DjQCtb+X0MH0Hvnw==", + "version": "8.13.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz", + "integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==", "dependencies": { - "pg-connection-string": "^2.6.4", - "pg-pool": "^3.6.2", - "pg-protocol": "^1.6.1", + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, @@ -8603,14 +8033,14 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.4.tgz", - "integrity": "sha512-v+Z7W/0EO707aNMaAEfiGnGL9sxxumwLl2fJvCQtMn9Fxsg+lPpPkdcyBSv/KFgpGdYkMfn+EI1Or2EHjpgLCA==" + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", + "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, "node_modules/pg-cursor": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.11.0.tgz", - "integrity": "sha512-TLCOCtu+rqMarzjUi+/Ffc2DV5ZqO/27y5GqnK9Z3w51rWXMwC8FcO96Uf9/ORo5o+qRXEVJxM9Ts3K2K31MLg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.12.1.tgz", + "integrity": "sha512-V13tEaA9Oq1w+V6Q3UBIB/blxJrwbbr35/dY54r/86soBJ7xkP236bXaORUTVXUPt9B6Ql2BQu+uwQiuMfRVgg==", "peerDependencies": { "pg": "^8" } @@ -8633,24 +8063,24 @@ } }, "node_modules/pg-pool": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.2.tgz", - "integrity": "sha512-Htjbg8BlwXqSBQ9V8Vjtc+vzf/6fVUuak/3/XXKA9oxZprwW3IMDQTGHP+KDmVL7rtd+R1QjbnCFPuTHm3G4hg==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz", + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", - "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz", + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==" }, "node_modules/pg-query-stream": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.6.0.tgz", - "integrity": "sha512-sg2Hewe6ge6osEY07zGu7Z8djrsQBvyiTy5ZjQffoSatEgnNNVsV3EWDm9Px/8R9oaAL1YnfnP8AXPMmfzujZg==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.7.1.tgz", + "integrity": "sha512-UMgsgn/pOIYsIifRySp59vwlpTpLADMK9HWJtq5ff0Z3MxBnPMGnCQeaQl5VuL+7ov4F96mSzIRIcz+Duo6OiQ==", "dependencies": { - "pg-cursor": "^2.11.0" + "pg-cursor": "^2.12.1" }, "peerDependencies": { "pg": "^8" @@ -8674,6 +8104,11 @@ "node": ">=10" } }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz", + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==" + }, "node_modules/pg/node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", @@ -8733,9 +8168,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, "node_modules/picomatch": { @@ -8984,6 +8419,19 @@ "node": ">= 6.0.0" } }, + "node_modules/prompt/node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/prompt/node_modules/async": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", + "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" + }, "node_modules/prompt/node_modules/winston": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/winston/-/winston-2.4.7.tgz", @@ -9025,11 +8473,6 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==" - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -9040,14 +8483,15 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { "side-channel": "^1.0.6" }, @@ -9188,6 +8632,29 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/readable-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/readdir-glob": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", @@ -9231,15 +8698,15 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.6", + "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" + "set-function-name": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -9333,6 +8800,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -9358,6 +8826,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -9427,12 +8896,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -9470,9 +8933,9 @@ } }, "node_modules/safe-stable-stringify": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", - "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", "engines": { "node": ">=10" } @@ -9483,12 +8946,9 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -9497,9 +8957,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -9532,6 +8992,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/send/node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -9543,29 +9011,24 @@ "node": ">=4" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, "node_modules/serialize-javascript": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", - "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "dependencies": { "randombytes": "^2.1.0" } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -9651,18 +9114,23 @@ "object-inspect": "^1.13.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -9716,15 +9184,6 @@ "sinon": ">=4.0.0" } }, - "node_modules/sinon/node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9770,6 +9229,25 @@ "node": ">=8" } }, + "node_modules/spawn-wrap/node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/spawn-wrap/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -9797,9 +9275,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", - "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", + "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", "dev": true }, "node_modules/split2": { @@ -9887,9 +9365,9 @@ } }, "node_modules/streamx": { - "version": "2.20.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.0.tgz", - "integrity": "sha512-ZGd1LhDeGFucr1CUCTBOS58ZhEendd0ttpGT3usTvosS4ntIwKN9LJFp+OeCSprsCPL14BXVRZlHGRY1V9PVzQ==", + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz", + "integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==", "dependencies": { "fast-fifo": "^1.3.2", "queue-tick": "^1.0.1", @@ -9908,16 +9386,19 @@ } }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/string-width-cjs": { @@ -9934,6 +9415,36 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.padend": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", @@ -10025,12 +9536,12 @@ } }, "node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/strip-json-comments": { @@ -10074,9 +9585,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.15.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.15.2.tgz", - "integrity": "sha512-60ym8SacBjcnpgqQ7Un2ZRaWjwRoGAVceBxMFap8JtwK0VW/GWg2W/BQVBskN9RuiK9Rh5QUA/pKfd4n1i51yw==" + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==" }, "node_modules/swagger-ui-express": { "version": "4.6.3", @@ -10138,6 +9649,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -10167,12 +9679,9 @@ } }, "node_modules/text-decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.1.1.tgz", - "integrity": "sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==", - "dependencies": { - "b4a": "^1.6.4" - } + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.1.tgz", + "integrity": "sha512-x9v3H/lTKIJKQQe7RPQkLfKAnc9lUTkWDypIQgTzPJAq+5/GCDHonmshfvlsNSj58yyshbIJJDLmU15qNERrXQ==" }, "node_modules/text-hex": { "version": "1.0.0", @@ -10193,15 +9702,6 @@ "node": ">=8" } }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10222,13 +9722,10 @@ } }, "node_modules/touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", "dev": true, - "dependencies": { - "nopt": "~1.0.10" - }, "bin": { "nodetouch": "bin/nodetouch.js" } @@ -10242,9 +9739,9 @@ } }, "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", + "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==", "dev": true, "engines": { "node": ">=16" @@ -10254,9 +9751,9 @@ } }, "node_modules/ts-log": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.5.tgz", - "integrity": "sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA==" + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.7.tgz", + "integrity": "sha512-320x5Ggei84AxzlXp91QkIGSw5wgaLT6GeAH0KsqDmRZdVWW2OiSeVvElVoatk3f7nicwXlElXsoFkARiGE2yg==" }, "node_modules/ts-mocha": { "version": "10.0.0", @@ -10310,6 +9807,15 @@ "node": ">=4.2.0" } }, + "node_modules/ts-mocha/node_modules/yn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -10362,15 +9868,6 @@ "node": ">=0.3.1" } }, - "node_modules/ts-node/node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -10397,10 +9894,20 @@ "json5": "lib/cli.js" } }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=4" + } + }, "node_modules/tslib": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, "node_modules/tunnel-ssh": { "version": "4.1.6", @@ -10443,9 +9950,9 @@ } }, "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", "dev": true, "engines": { "node": ">=4" @@ -10609,9 +10116,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "dev": true, "funding": [ { @@ -10628,8 +10135,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -10642,6 +10149,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -10756,15 +10264,15 @@ } }, "node_modules/winston": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.13.0.tgz", - "integrity": "sha512-rwidmA1w3SE4j0E5MuIufFhyJPBDG7Nu71RkZor1p2+qHvJSZ9GYDA81AyleQcZbh/+V6HjeBdfnTZJm9rSeQQ==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.15.0.tgz", + "integrity": "sha512-RhruH2Cj0bV0WgNL+lOfoUBI4DVfdUNjVnJGVovWZmrcKtrFTTRzgXYK2O9cymSGjrERCtaAeHwMNnUWXlwZow==", "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", - "logform": "^2.4.0", + "logform": "^2.6.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", @@ -10794,19 +10302,19 @@ } }, "node_modules/winston-transport": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.0.tgz", - "integrity": "sha512-ajBj65K5I7denzer2IYW6+2bNIVqLGDHqDw3Ow8Ohh+vdW+rv4MZ6eiDvHoKhfJFZ2auyN8byXieDDJ96ViONg==", + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.8.0.tgz", + "integrity": "sha512-qxSTKswC6llEMZKgCQdaWgDuMJQnhuvF5f2Nk3SNXc4byfQ+voo2mX1Px9dkNOuR8p0KAjfPG29PuYUSIb+vSA==", "dependencies": { - "logform": "^2.3.2", - "readable-stream": "^3.6.0", + "logform": "^2.6.1", + "readable-stream": "^4.5.2", "triple-beam": "^1.3.0" }, "engines": { "node": ">= 12.0.0" } }, - "node_modules/winston-transport/node_modules/readable-stream": { + "node_modules/winston/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", @@ -10819,44 +10327,35 @@ "node": ">= 6" } }, - "node_modules/winston/node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/winston/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, "node_modules/workerpool": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", - "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", "dev": true }, "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { @@ -10876,10 +10375,65 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true }, "node_modules/write-file-atomic": { "version": "3.0.3", @@ -10893,10 +10447,22 @@ "typedarray-to-buffer": "^3.1.5" } }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, "node_modules/xlsx": { "version": "0.19.3", "resolved": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz", - "integrity": "sha512-8IfgFctB7fkvqkTGF2MnrDrC6vzE28Wcc1aSbdDQ+4/WFtzfS73YuapbuaPZwGqpR2e0EeDMIrFOJubQVLWFNA==" + "integrity": "sha512-8IfgFctB7fkvqkTGF2MnrDrC6vzE28Wcc1aSbdDQ+4/WFtzfS73YuapbuaPZwGqpR2e0EeDMIrFOJubQVLWFNA==", + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } }, "node_modules/xtend": { "version": "4.0.2", @@ -10938,9 +10504,9 @@ } }, "node_modules/yargs-parser": { - "version": "20.2.4", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", - "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, "engines": { "node": ">=10" @@ -10994,6 +10560,11 @@ "node": ">=8" } }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, "node_modules/yargs/node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -11042,6 +10613,19 @@ "node": ">=8" } }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yargs/node_modules/yargs-parser": { "version": "18.1.3", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", @@ -11055,12 +10639,12 @@ } }, "node_modules/yn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", - "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "dev": true, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/yocto-queue": { diff --git a/api/src/app.ts b/api/src/app.ts index a105802b8f..42a1de6a02 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -13,20 +13,24 @@ import { } from './middleware/critterbase-proxy'; import { rootAPIDoc } from './openapi/root-api-doc'; import { authenticateRequest, authenticateRequestOptional } from './request-handlers/security/authentication'; +import { loadEvironmentVariables } from './utils/env-config'; import { scanFileForVirus } from './utils/file-utils'; import { getLogger } from './utils/logger'; +// Load and validate the environment variables +loadEvironmentVariables(); + const defaultLog = getLogger('app'); const HOST = process.env.API_HOST; -const PORT = Number(process.env.API_PORT); +const PORT = process.env.API_PORT; // Max size of the body of the request (bytes) -const MAX_REQ_BODY_SIZE = Number(process.env.MAX_REQ_BODY_SIZE) || 52428800; +const MAX_REQ_BODY_SIZE = process.env.MAX_REQ_BODY_SIZE; // Max number of files in a single request -const MAX_UPLOAD_NUM_FILES = Number(process.env.MAX_UPLOAD_NUM_FILES) || 10; +const MAX_UPLOAD_NUM_FILES = process.env.MAX_UPLOAD_NUM_FILES; // Max size of a single file (bytes) -const MAX_UPLOAD_FILE_SIZE = Number(process.env.MAX_UPLOAD_FILE_SIZE) || 52428800; +const MAX_UPLOAD_FILE_SIZE = process.env.MAX_UPLOAD_FILE_SIZE; // Get initial express app const app: express.Express = express(); diff --git a/api/src/constants/attachments.ts b/api/src/constants/attachments.ts index 0dd2f7f738..030ff6531e 100644 --- a/api/src/constants/attachments.ts +++ b/api/src/constants/attachments.ts @@ -18,6 +18,9 @@ export enum ATTACHMENT_TYPE { export enum TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE { /** * Lotek API key file type. + * + * @export + * @enum {string} */ KEYX = 'KeyX', /** @@ -25,3 +28,17 @@ export enum TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE { */ CFG = 'Cfg' } + +export enum CRITTER_CAPTURE_ATTACHMENT_TYPE { + /** + * Critter Capture Attachment file type. + * + * Note: This will not be used as the attachment type on the record. + * But used to identify which service to get the S3 key from in the endpoint. + * + * @export + * @enum {string} + */ + CAPTURE = 'Capture', + MORTALITY = 'Mortality' +} diff --git a/api/src/constants/dates.ts b/api/src/constants/dates.ts new file mode 100644 index 0000000000..00722e6895 --- /dev/null +++ b/api/src/constants/dates.ts @@ -0,0 +1,22 @@ +/* + * Date formats. + * + * See BC Gov standards: https://www2.gov.bc.ca/gov/content/governments/services-for-government/policies-procedures/web-content-development-guides/writing-for-the-web/web-style-guide/numbers + */ +export const DefaultDateFormat = 'YYYY-MM-DD'; // 2020-01-05 + +export const DefaultDateFormatReverse = 'DD-MM-YYYY'; // 05-01-2020 + +export const AltDateFormat = 'YYYY/MM/DD'; // 2020/01/05 + +export const AltDateFormatReverse = 'DD/MM/YYYY'; // 05/01/2020 + +/* + * Time formats. + */ +export const DefaultTimeFormat = 'HH:mm:ss'; // 23:00:00 + +/* + * Datetime formats. + */ +export const DefaultDateTimeFormat = `${DefaultDateFormat}T${DefaultTimeFormat}`; // 2020-01-05T23:00:00 diff --git a/api/src/database-models/critter_capture_attachment.ts b/api/src/database-models/critter_capture_attachment.ts new file mode 100644 index 0000000000..9901c37da6 --- /dev/null +++ b/api/src/database-models/critter_capture_attachment.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +/** + * Note: These files should only contain the `Data Models` and `Data Records` with equivalent inferred types. + * + * Data Models contain a 1 to 1 mapping of the database table. + * + * Data Records contain a 1 to 1 mapping of the database table, minus the audit columns. + */ + +/** + * Critter Capture Attachment Model. + * + * @description Data model for `critter_capture_attachment`. + */ +export const CritterCaptureAttachmentModel = z.object({ + critter_capture_attachment_id: z.number(), + uuid: z.string().nullable(), + critter_id: z.number(), + critterbase_capture_id: z.string(), + file_type: z.string(), + file_name: z.string().nullable(), + file_size: z.number().nullable(), + title: z.string().nullable(), + description: z.string().nullable(), + key: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type CritterCaptureAttachmentModel = z.infer; + +/** + * Critter Capture Attachment Record. + * + * @description Data record for `critter_capture_attachment`. + */ +export const CritterCaptureAttachmentRecord = CritterCaptureAttachmentModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type CritterCaptureAttachmentRecord = z.infer; diff --git a/api/src/database-models/critter_mortality_attachment.ts b/api/src/database-models/critter_mortality_attachment.ts new file mode 100644 index 0000000000..f643cbf272 --- /dev/null +++ b/api/src/database-models/critter_mortality_attachment.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +/** + * Note: These files should only contain the `Data Models` and `Data Records` with equivalent inferred types. + * + * Data Models contain a 1 to 1 mapping of the database table. + * + * Data Records contain a 1 to 1 mapping of the database table, minus the audit columns. + */ + +/** + * Critter Mortality Attachment Model. + * + * @description Data model for `critter_mortality_attachment`. + */ +export const CritterMortalityAttachmentModel = z.object({ + critter_mortality_attachment_id: z.number(), + uuid: z.string().nullable(), + critter_id: z.string(), + critterbase_mortality_id: z.string(), + file_type: z.string(), + file_name: z.string().nullable(), + file_size: z.number().nullable(), + title: z.string().nullable(), + description: z.string().nullable(), + key: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type CritterMortalityAttachmentModel = z.infer; + +/** + * Critter Mortality Attachment Record. + * + * @description Data record for `critter_mortality_attachment`. + */ +export const CritterMortalityAttachmentRecord = CritterMortalityAttachmentModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type CritterMortalityAttachmentRecord = z.infer; diff --git a/api/src/database-models/markdown.ts b/api/src/database-models/markdown.ts new file mode 100644 index 0000000000..e13f240989 --- /dev/null +++ b/api/src/database-models/markdown.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * Markdown Model. + * + * @description Data model for `markdown`. + */ +export const MarkdownModel = z.object({ + markdown_id: z.number(), + markdown_type_id: z.number(), + data: z.string().nullable(), + score: z.number(), + record_end_date: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type MarkdownModel = z.infer; + +/** + * Markdown Record. + * + * @description Data record for `markdown`. + */ +export const MarkdownRecord = MarkdownModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type MarkdownRecord = z.infer; diff --git a/api/src/database-models/markdown_type.ts b/api/src/database-models/markdown_type.ts new file mode 100644 index 0000000000..d40df8b6ed --- /dev/null +++ b/api/src/database-models/markdown_type.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Markdown Type Model. + * + * @description Data model for `markdown_type`. + */ +export const MarkdownTypeModel = z.object({ + markdown_type_id: z.number(), + name: z.string(), + description: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type MarkdownTypeModel = z.infer; + +/** + * Markdown Type Record. + * + * @description Data record for `markdown_type`. + */ +export const MarkdownTypeRecord = MarkdownTypeModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type MarkdownTypeRecord = z.infer; diff --git a/api/src/database-models/markdown_user.ts b/api/src/database-models/markdown_user.ts new file mode 100644 index 0000000000..401421d6f6 --- /dev/null +++ b/api/src/database-models/markdown_user.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Markdown User Model. + * + * @description Data model for `markdown_user`. + */ +export const MarkdownUserModel = z.object({ + markdown_user_id: z.number(), + system_user_id: z.number(), + markdown_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type MarkdownUserModel = z.infer; + +/** + * Markdown User Record. + * + * @description Data record for `markdown_user`. + */ +export const MarkdownUserRecord = MarkdownUserModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type MarkdownUserRecord = z.infer; diff --git a/api/src/database-models/method_technique.ts b/api/src/database-models/method_technique.ts new file mode 100644 index 0000000000..e8288d351f --- /dev/null +++ b/api/src/database-models/method_technique.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +/** + * Method Technique Model. + * + * @description Data model for `method_technique`. + */ +export const MethodTechniqueModel = z.object({ + method_technique_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string().nullable(), + distance_threshold: z.number().nullable(), + method_lookup_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type MethodTechniqueModel = z.infer; + +/** + * Method Technique Record. + * + * @description Data record for `method_technique`. + */ +export const MethodTechniqueRecord = MethodTechniqueModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type MethodTechniqueRecord = z.infer; diff --git a/api/src/database-models/survey_block.ts b/api/src/database-models/survey_block.ts new file mode 100644 index 0000000000..1a7efd430b --- /dev/null +++ b/api/src/database-models/survey_block.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +/** + * Survey Block Model. + * + * @description Data model for `survey_block`. + */ +export const SurveyBlockModel = z.object({ + survey_block_id: z.number(), + survey_id: z.number(), + name: z.string().nullable(), + description: z.string().nullable(), + geometry: z.null(), + geography: z.string(), + geojson: z.any(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveyBlockModel = z.infer; + +/** + * Survey Block Record. + * + * @description Data record for `survey_block`. + */ +export const SurveyBlockRecord = SurveyBlockModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveyBlockRecord = z.infer; diff --git a/api/src/database-models/survey_sample_block.ts b/api/src/database-models/survey_sample_block.ts new file mode 100644 index 0000000000..6916659bf5 --- /dev/null +++ b/api/src/database-models/survey_sample_block.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Survey Sample Block Model. + * + * @description Data model for `survey_sample_block`. + */ +export const SurveySampleBlockModel = z.object({ + survey_sample_block_id: z.number(), + survey_sample_site_id: z.number(), + survey_block_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleBlockModel = z.infer; + +/** + * Survey Sample Block Record. + * + * @description Data record for `survey_sample_block`. + */ +export const SurveySampleBlockRecord = SurveySampleBlockModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleBlockRecord = z.infer; diff --git a/api/src/database-models/survey_sample_method.ts b/api/src/database-models/survey_sample_method.ts new file mode 100644 index 0000000000..f83b693f78 --- /dev/null +++ b/api/src/database-models/survey_sample_method.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * Survey Sample Method Model. + * + * @description Data model for `survey_sample_method`. + */ +export const SurveySampleMethodModel = z.object({ + survey_sample_method_id: z.number(), + survey_sample_site_id: z.number(), + description: z.string().nullable(), + method_response_metric_id: z.number(), + method_technique_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleMethodModel = z.infer; + +/** + * Survey Sample Method Record. + * + * @description Data record for `survey_sample_method`. + */ +export const SurveySampleMethodRecord = SurveySampleMethodModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleMethodRecord = z.infer; diff --git a/api/src/database-models/survey_sample_period.ts b/api/src/database-models/survey_sample_period.ts new file mode 100644 index 0000000000..9801a579b5 --- /dev/null +++ b/api/src/database-models/survey_sample_period.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +/** + * Survey Sample Period Model. + * + * @description Data model for `survey_sample_period`. + */ +export const SurveySamplePeriodModel = z.object({ + survey_sample_period_id: z.number(), + survey_sample_method_id: z.number(), + start_date: z.string().nullable(), + end_date: z.string().nullable(), + start_time: z.string().nullable(), + end_time: z.string().nullable(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySamplePeriodModel = z.infer; + +/** + * Survey Sample Period Record. + * + * @description Data record for `survey_sample_period`. + */ +export const SurveySamplePeriodRecord = SurveySamplePeriodModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySamplePeriodRecord = z.infer; diff --git a/api/src/database-models/survey_sample_site.ts b/api/src/database-models/survey_sample_site.ts new file mode 100644 index 0000000000..0a6697b735 --- /dev/null +++ b/api/src/database-models/survey_sample_site.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +/** + * Survey Sample Site Model. + * + * @description Data model for `survey_sample_site`. + */ +export const SurveySampleSiteModel = z.object({ + survey_sample_site_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string().nullable(), + geometry: z.null(), + geography: z.string(), + geojson: z.any(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleSiteModel = z.infer; + +/** + * Survey Sample Site Record. + * + * @description Data record for `survey_sample_site`. + */ +export const SurveySampleSiteRecord = SurveySampleSiteModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleSiteRecord = z.infer; diff --git a/api/src/database-models/survey_sample_stratum.ts b/api/src/database-models/survey_sample_stratum.ts new file mode 100644 index 0000000000..9274a6a74c --- /dev/null +++ b/api/src/database-models/survey_sample_stratum.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Survey Sample Stratum Model. + * + * @description Data model for `survey_sample_stratum`. + */ +export const SurveySampleStratumModel = z.object({ + survey_sample_stratum_id: z.number(), + survey_sample_site_id: z.number(), + survey_stratum_id: z.number(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveySampleStratumModel = z.infer; + +/** + * Survey Sample Stratum Record. + * + * @description Data record for `survey_sample_stratum`. + */ +export const SurveySampleStratumRecord = SurveySampleStratumModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveySampleStratumRecord = z.infer; diff --git a/api/src/database-models/survey_stratum.ts b/api/src/database-models/survey_stratum.ts new file mode 100644 index 0000000000..6260431e80 --- /dev/null +++ b/api/src/database-models/survey_stratum.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +/** + * Survey Stratum Model. + * + * @description Data model for `survey_stratum`. + */ +export const SurveyStratumModel = z.object({ + survey_stratum_id: z.number(), + survey_id: z.number(), + name: z.string(), + description: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SurveyStratumModel = z.infer; + +/** + * Survey Stratum Record. + * + * @description Data record for `survey_stratum`. + */ +export const SurveyStratumRecord = SurveyStratumModel.omit({ + create_date: true, + create_user: true, + update_date: true, + update_user: true, + revision_count: true +}); + +export type SurveyStratumRecord = z.infer; diff --git a/api/src/database/db.ts b/api/src/database/db.ts index a213567c86..8d598f1981 100644 --- a/api/src/database/db.ts +++ b/api/src/database/db.ts @@ -18,14 +18,14 @@ import { asyncErrorWrapper, getGenericizedKeycloakUserInformation, syncErrorWrap const defaultLog = getLogger('database/db'); const getDbHost = () => process.env.DB_HOST; -const getDbPort = () => Number(process.env.DB_PORT); +const getDbPort = () => process.env.DB_PORT; const getDbUsername = () => process.env.DB_USER_API; const getDbPassword = () => process.env.DB_USER_API_PASS; const getDbDatabase = () => process.env.DB_DATABASE; -const DB_POOL_SIZE: number = Number(process.env.DB_POOL_SIZE) || 20; -const DB_CONNECTION_TIMEOUT: number = Number(process.env.DB_CONNECTION_TIMEOUT) || 0; -const DB_IDLE_TIMEOUT: number = Number(process.env.DB_IDLE_TIMEOUT) || 10000; +const DB_POOL_SIZE = 20; +const DB_CONNECTION_TIMEOUT = 0; +const DB_IDLE_TIMEOUT = 10000; export const DB_CLIENT = 'pg'; diff --git a/api/src/models/alert-view.ts b/api/src/models/alert-view.ts new file mode 100644 index 0000000000..f57e2fffa5 --- /dev/null +++ b/api/src/models/alert-view.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +// Define the alert schema +export const IAlert = z.object({ + alert_id: z.number(), + alert_type_id: z.number().int(), + name: z.string(), + message: z.string(), + severity: z.enum(['info', 'success', 'error', 'warning']), + data: z.object({}).nullable(), + record_end_date: z.string().nullable(), + status: z.enum(['active', 'expired']) +}); + +// Infer types from the schema +export type IAlert = z.infer; +export type IAlertCreateObject = Omit; +export type IAlertUpdateObject = Omit; + +// Filter object for viewing alerts +export interface IAlertFilterObject { + expiresBefore?: string; + expiresAfter?: string; + types?: string[]; +} + +// Define severity and status types +export type IAlertSeverity = 'info' | 'success' | 'error' | 'warning'; +export type IAlertStatus = 'active' | 'expired'; diff --git a/api/src/models/markdown-view.ts b/api/src/models/markdown-view.ts new file mode 100644 index 0000000000..c8a70963af --- /dev/null +++ b/api/src/models/markdown-view.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { MarkdownRecord } from '../database-models/markdown'; + +export const MarkdownObject = MarkdownRecord.pick({ + markdown_id: true, + markdown_type_id: true, + data: true +}).extend({ + participated: z.boolean() +}); + +export type MarkdownObject = z.infer; + +export interface MarkdownQueryObject { + system_user_id: number; + markdown_type_name: string; +} diff --git a/api/src/models/project-survey-attachments.ts b/api/src/models/project-survey-attachments.ts index 7f025337f2..f7eef80b3c 100644 --- a/api/src/models/project-survey-attachments.ts +++ b/api/src/models/project-survey-attachments.ts @@ -1,4 +1,4 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import { ATTACHMENT_TYPE } from '../constants/attachments'; import { getLogger } from '../utils/logger'; import { SurveySupplementaryData } from './survey-view'; diff --git a/api/src/models/sampling-locations-view.ts b/api/src/models/sampling-locations-view.ts new file mode 100644 index 0000000000..59cf1e842c --- /dev/null +++ b/api/src/models/sampling-locations-view.ts @@ -0,0 +1,19 @@ +export interface ISiteAdvancedFilters { + survey_id?: number; + keyword?: string; + system_user_id?: number; +} + +export interface IMethodAdvancedFilters { + survey_id?: number; + sample_site_id?: number; + keyword?: string; + system_user_id?: number; +} + +export interface IPeriodAdvancedFilters { + survey_id?: number; + sample_site_id?: number; + sample_method_id?: number; + system_user_id?: number; +} diff --git a/api/src/openapi/schemas/alert.ts b/api/src/openapi/schemas/alert.ts new file mode 100644 index 0000000000..312681feb9 --- /dev/null +++ b/api/src/openapi/schemas/alert.ts @@ -0,0 +1,83 @@ +import { OpenAPIV3 } from 'openapi-types'; + +/** + * Base schema for system alerts + */ +const baseSystemAlertSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'Schema defining alerts created by system administrators.', + additionalProperties: false, + properties: { + name: { + description: 'Name to display as the title of the alert', + type: 'string' + }, + message: { + description: 'Message to display on the alert', + type: 'string' + }, + alert_type_id: { + description: 'Type of the alert, controlling how it is displayed.', + type: 'number' + }, + severity: { + description: 'Severity level of the alert', + type: 'string', + enum: ['info', 'success', 'warning', 'error'] + }, + data: { + description: 'Data associated with the alert', + type: 'object', + nullable: true + }, + record_end_date: { + description: 'End date of the alert', + type: 'string', + nullable: true + } + } +}; + +/** + * Schema for updating system alerts + */ +export const systemAlertPutSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity'], + additionalProperties: false, + properties: { + ...baseSystemAlertSchema.properties, + alert_id: { + type: 'integer', + minimum: 1, + description: 'Primary key of the alert' + } + } +}; + +/** + * Schema for getting system alerts + */ +export const systemAlertGetSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + required: ['alert_id', 'name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity', 'status'], + additionalProperties: false, + properties: { + ...systemAlertPutSchema.properties, + status: { + type: 'string', + enum: ['active', 'expired'], + description: + 'Status of the alert based on comparing the current date to record_end_date, calculated in the get query.' + } + } +}; + +/** + * Schema for creating system alerts + */ +export const systemAlertCreateSchema: OpenAPIV3.SchemaObject = { + ...baseSystemAlertSchema, + additionalProperties: false, + required: ['name', 'message', 'data', 'alert_type_id', 'record_end_date', 'severity'] +}; diff --git a/api/src/openapi/schemas/csv.ts b/api/src/openapi/schemas/csv.ts new file mode 100644 index 0000000000..8c18600e35 --- /dev/null +++ b/api/src/openapi/schemas/csv.ts @@ -0,0 +1,41 @@ +import { OpenAPIV3 } from 'openapi-types'; + +/** + * CSV validation error object schema + * + */ +export const CSVErrorSchema: OpenAPIV3.SchemaObject = { + title: 'CSV validation error object', + type: 'object', + additionalProperties: false, + required: ['error', 'solution', 'row'], + properties: { + error: { + description: 'The error message', + type: 'string' + }, + solution: { + description: 'The error solution or instructions to resolve', + type: 'string' + }, + values: { + description: 'The list of allowed values if applicable', + type: 'array', + items: { + oneOf: [{ type: 'string' }, { type: 'number' }] + } + }, + cell: { + description: 'The CSV cell value', + oneOf: [{ type: 'string' }, { type: 'number' }] + }, + header: { + description: 'The header name used in the CSV file', + type: 'string' + }, + row: { + description: 'The row index the error occurred. Header row index 0. First data row index 1.', + type: 'number' + } + } +}; diff --git a/api/src/openapi/schemas/markdown.ts b/api/src/openapi/schemas/markdown.ts new file mode 100644 index 0000000000..a4141f6858 --- /dev/null +++ b/api/src/openapi/schemas/markdown.ts @@ -0,0 +1,40 @@ +import { OpenAPIV3 } from 'openapi-types'; + +/** + * Schema for markdown records used in versioned help dialogs + * + */ +export const markdownSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'Schema for get markdown response', + additionalProperties: false, + required: ['markdown'], + properties: { + markdown: { + type: 'object', + description: 'Markdown record', + required: ['markdown_id', 'markdown_type_id', 'data', 'participated'], + additionalProperties: false, + properties: { + markdown_id: { + type: 'number', + description: 'Primary key of the markdown record', + minimum: 1 + }, + markdown_type_id: { + type: 'number', + description: 'Type of the markdown record, used to identify which records correspond to which dialogs', + minimum: 1 + }, + data: { + type: 'string', + description: 'Markdown string to display' + }, + participated: { + type: 'boolean', + description: 'True if the user has already scored the markdown record, otherwise false.' + } + } + } + } +}; diff --git a/api/src/openapi/schemas/observation.ts b/api/src/openapi/schemas/observation.ts index 4f5e82b78e..00c8579f24 100644 --- a/api/src/openapi/schemas/observation.ts +++ b/api/src/openapi/schemas/observation.ts @@ -1,5 +1,6 @@ import { OpenAPIV3 } from 'openapi-types'; import { paginationResponseSchema } from './pagination'; +import { SampleLocationSchema } from './sample-site'; export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { type: 'object', @@ -60,19 +61,27 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { nullable: true }, latitude: { - type: 'number' + type: 'number', + nullable: true, + minimum: -90, + maximum: 90 }, longitude: { - type: 'number' + type: 'number', + nullable: true, + minimum: -180, + maximum: 180 }, count: { type: 'integer' }, observation_date: { - type: 'string' + type: 'string', + nullable: true }, observation_time: { - type: 'string' + type: 'string', + nullable: true }, survey_sample_site_name: { type: 'string', @@ -95,6 +104,7 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { 'observation_subcount_id', 'subcount', 'observation_subcount_sign_id', + 'comment', 'qualitative_measurements', 'quantitative_measurements', 'qualitative_environments', @@ -110,6 +120,11 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { description: 'The observation subcount sign ID, indicating whether the subcount was a direct sighting, footprints, scat, etc.' }, + comment: { + type: 'string', + nullable: true, + description: 'A comment or note about the subcount record.' + }, subcount: { type: 'number' }, @@ -211,7 +226,8 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { 'qualitative_measurements', 'quantitative_measurements', 'qualitative_environments', - 'quantitative_environments' + 'quantitative_environments', + 'sample_sites' ], properties: { observationCount: { @@ -398,7 +414,8 @@ export const observervationsWithSubcountDataSchema: OpenAPIV3.SchemaObject = { } } } - } + }, + sample_sites: SampleLocationSchema } }, pagination: { ...paginationResponseSchema } diff --git a/api/src/openapi/schemas/sample-site.ts b/api/src/openapi/schemas/sample-site.ts new file mode 100644 index 0000000000..8e6582892b --- /dev/null +++ b/api/src/openapi/schemas/sample-site.ts @@ -0,0 +1,156 @@ +import { OpenAPIV3 } from 'openapi-types'; +import { techniqueSimpleViewSchema } from './technique'; + +export const SampleLocationSchema: OpenAPIV3.SchemaObject = { + type: 'array', + description: 'Sample location response object (includes sites, techniques, periods, stratums, blocks).', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string', + maxLength: 50 + }, + description: { + type: 'string', + maxLength: 250 + }, + geometry_type: { + type: 'string', + maxLength: 50 + }, + sample_methods: { + type: 'array', + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'technique', + 'method_response_metric_id', + 'sample_periods' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + technique: techniqueSimpleViewSchema, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, + description: { + type: 'string', + maxLength: 250 + }, + sample_periods: { + type: 'array', + required: [ + 'survey_sample_period_id', + 'survey_sample_method_id', + 'start_date', + 'start_time', + 'end_date', + 'end_time' + ], + items: { + type: 'object', + additionalProperties: false, + properties: { + survey_sample_period_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_date: { + type: 'string' + }, + end_time: { + type: 'string', + nullable: true + } + } + } + } + } + } + }, + blocks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_block_id', 'survey_sample_site_id', 'survey_block_id'], + properties: { + survey_sample_block_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_stratum_id', 'survey_sample_site_id', 'survey_stratum_id'], + properties: { + survey_sample_stratum_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_stratum_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + } +}; diff --git a/api/src/openapi/schemas/survey.ts b/api/src/openapi/schemas/survey.ts index 6e9a35a298..035cb5212c 100644 --- a/api/src/openapi/schemas/survey.ts +++ b/api/src/openapi/schemas/survey.ts @@ -1,4 +1,5 @@ import { OpenAPIV3 } from 'openapi-types'; +import { GeoJSONFeature } from './geoJson'; import { updateCreateUserPropertiesSchema } from './user'; export const surveyDetailsSchema: OpenAPIV3.SchemaObject = { @@ -483,7 +484,7 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { title: 'Survey Block', type: 'object', additionalProperties: false, - required: ['name', 'description'], + required: ['name', 'description', 'geojson'], properties: { survey_block_id: { description: 'Survey block id', @@ -494,18 +495,21 @@ export const surveyBlockSchema: OpenAPIV3.SchemaObject = { survey_id: { description: 'Survey id', type: 'integer', - nullable: true + minimum: 1 }, name: { description: 'Name', - type: 'string', - nullable: true + type: 'string' }, description: { description: 'Description', type: 'string', nullable: true }, + geojson: { + ...(GeoJSONFeature as object), + nullable: true + }, sample_block_count: { description: 'Sample block count', type: 'number' diff --git a/api/src/openapi/schemas/technique.ts b/api/src/openapi/schemas/technique.ts index fb0fd24dc3..6eee864bd6 100644 --- a/api/src/openapi/schemas/technique.ts +++ b/api/src/openapi/schemas/technique.ts @@ -152,3 +152,27 @@ export const techniqueViewSchema: OpenAPIV3.SchemaObject = { } } }; + +export const vantageModeSchema: OpenAPIV3.SchemaObject = { + type: 'object', + description: 'Vantage modes allowed for method lookup options that can be applied to a technique', + required: ['vantage_modes'], + additionalProperties: false, + properties: { + vantage_modes: { + type: 'array', + description: 'Possible vantage modes', + items: { + type: 'object', + required: ['vantage_mode_id', 'name', 'vantage_id', 'description'], + additionalProperties: false, + properties: { + vantage_mode_id: { type: 'string', description: 'The primary key of the vantage mode option.' }, + name: { type: 'string', description: 'The name of the vantage mode option.' }, + vantage_id: { type: 'string', description: 'The vantage of the mode.' }, + description: { type: 'string', description: 'The description of the mode option.' } + } + } + } + } +}; diff --git a/api/src/paths/administrative-activities.ts b/api/src/paths/administrative-activities.ts index 31b7c2fbd1..e8ec87caa0 100644 --- a/api/src/paths/administrative-activities.ts +++ b/api/src/paths/administrative-activities.ts @@ -174,6 +174,7 @@ export function getAdministrativeActivities(): RequestHandler { return res.status(200).json(response); } catch (error) { defaultLog.error({ label: 'getAdministrativeActivities', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/administrative-activity.ts b/api/src/paths/administrative-activity.ts index 7205e91d8e..9cdcf62482 100644 --- a/api/src/paths/administrative-activity.ts +++ b/api/src/paths/administrative-activity.ts @@ -23,6 +23,7 @@ POST.apiDoc = { ], requestBody: { description: 'Administrative Activity post request object.', + required: true, content: { 'application/json': { schema: { @@ -201,6 +202,7 @@ export function getAdministrativeActivityStanding(): RequestHandler { return res.status(200).json(response); } catch (error) { defaultLog.error({ label: 'getAdministrativeActivityStanding', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/approve.ts b/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/approve.ts index dbff48abe5..ed4c2db821 100644 --- a/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/approve.ts +++ b/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/approve.ts @@ -57,6 +57,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { @@ -175,6 +176,7 @@ export function approveAccessRequest(): RequestHandler { return res.status(200).send(); } catch (error) { defaultLog.error({ label: 'updateAccessRequest', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/reject.ts b/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/reject.ts index 77bb24a6e2..1c587760a1 100644 --- a/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/reject.ts +++ b/api/src/paths/administrative-activity/system-access/{administrativeActivityId}/reject.ts @@ -85,6 +85,7 @@ export function rejectAccessRequest(): RequestHandler { return res.status(200).send(); } catch (error) { defaultLog.error({ label: 'updateAccessRequest', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/alert/index.test.ts b/api/src/paths/alert/index.test.ts new file mode 100644 index 0000000000..82243c1846 --- /dev/null +++ b/api/src/paths/alert/index.test.ts @@ -0,0 +1,141 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { SYSTEM_IDENTITY_SOURCE } from '../../constants/database'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { IAlertSeverity, IAlertStatus } from '../../models/alert-view'; +import { AlertService } from '../../services/alert-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; +import { createAlert, getAlerts } from '../alert'; + +chai.use(sinonChai); + +describe('getAlerts', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('returns a list of system alerts', async () => { + const mockAlerts = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + }, + { + alert_id: 2, + name: 'Alert 2', + message: 'Message 2', + alert_type_id: 2, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + } + ]; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlerts').resolves(mockAlerts); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [3], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = getAlerts(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alerts: mockAlerts }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlerts').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = getAlerts(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('createAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + it('creates a new alert', async () => { + const mockAlert = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + severity: 'medium' + }; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'createAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.body = mockAlert; + + const requestHandler = createAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'createAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + const requestHandler = createAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/alert/index.ts b/api/src/paths/alert/index.ts new file mode 100644 index 0000000000..df67c3fb3d --- /dev/null +++ b/api/src/paths/alert/index.ts @@ -0,0 +1,244 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../constants/roles'; +import { getDBConnection } from '../../database/db'; +import { IAlertFilterObject } from '../../models/alert-view'; +import { systemAlertCreateSchema, systemAlertGetSchema } from '../../openapi/schemas/alert'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { AlertService } from '../../services/alert-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/alert/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.PROJECT_CREATOR, SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getAlerts() +]; + +GET.apiDoc = { + description: 'Gets a list of system alerts.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'types', + required: false, + schema: { + type: 'array', + items: { + type: 'string' + } + } + }, + { + in: 'query', + name: 'expiresBefore', + required: false, + schema: { + type: 'string' + } + }, + { + in: 'query', + name: 'expiresAfter', + required: false, + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + description: 'Response object containing system alerts', + additionalProperties: false, + required: ['alerts'], + properties: { + alerts: { type: 'array', description: 'Array of system alerts', items: systemAlertGetSchema } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get system alerts created by system administrators describing important information, deadlines, etc. + * + * @returns {RequestHandler} + */ +export function getAlerts(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getAlerts' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const filterObject = parseQueryParams(req); + + const alertService = new AlertService(connection); + + const alerts = await alertService.getAlerts(filterObject); + + await connection.commit(); + + return res.status(200).json({ alerts: alerts }); + } catch (error) { + defaultLog.error({ label: 'getAlerts', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IAlertFilterObject} + */ +function parseQueryParams(req: Request): IAlertFilterObject { + return { + expiresBefore: req.query.expiresBefore ?? undefined, + expiresAfter: req.query.expiresAfter ?? undefined, + types: req.query.types ?? [] + }; +} + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + createAlert() +]; + +POST.apiDoc = { + description: 'Create an alert.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + requestBody: { + description: 'Alert post request object.', + required: true, + content: { + 'application/json': { + schema: systemAlertCreateSchema + } + } + }, + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Creates a new system alert + * + * @returns {RequestHandler} + */ +export function createAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'createAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alert = req.body; + + const alertService = new AlertService(connection); + + const id = await alertService.createAlert(alert); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'createAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/alert/{alertId}/index.test.ts b/api/src/paths/alert/{alertId}/index.test.ts new file mode 100644 index 0000000000..e10b9c1d78 --- /dev/null +++ b/api/src/paths/alert/{alertId}/index.test.ts @@ -0,0 +1,246 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { deleteAlert, getAlertById, updateAlert } from '.'; +import { SYSTEM_IDENTITY_SOURCE } from '../../../constants/database'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import * as db from '../../../database/db'; +import { HTTPError } from '../../../errors/http-error'; +import { IAlertSeverity, IAlertStatus } from '../../../models/alert-view'; +import { AlertService } from '../../../services/alert-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; + +chai.use(sinonChai); + +describe('getAlerts', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('returns a single system alert', async () => { + const mockAlert = { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + severity: 'error' as IAlertSeverity, + status: 'active' as IAlertStatus, + data: null, + record_end_date: null + }; + + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlertById').resolves(mockAlert); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [3], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = getAlertById(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql(mockAlert); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'getAlertById').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + const requestHandler = getAlertById(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('deleteAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system user', () => { + it('rejects an unauthorized request', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.PROJECT_CREATOR], // Creators cannot delete alerts + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = deleteAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); + + describe('as a system admin user', () => { + it('deletes an alert and returns the alert id', async () => { + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = deleteAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'deleteAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + + const requestHandler = deleteAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); + +describe('updateAlert', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('as a system admin user', () => { + it('updates an alert', async () => { + const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + sinon.stub(AlertService.prototype, 'updateAlert').resolves(1); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + mockReq.system_user = { + system_user_id: 2, + user_identifier: 'username', + identity_source: SYSTEM_IDENTITY_SOURCE.IDIR, + user_guid: '123-456-789', + record_end_date: null, + role_ids: [1], + role_names: [SYSTEM_ROLE.SYSTEM_ADMIN], + email: 'email@email.com', + family_name: 'lname', + given_name: 'fname', + display_name: 'test user', + agency: null + }; + + const requestHandler = updateAlert(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ alert_id: 1 }); + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ rollback: sinon.stub(), release: sinon.stub() }); + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + sinon.stub(AlertService.prototype, 'updateAlert').rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params.alertId = '1'; + + const requestHandler = updateAlert(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); + }); +}); diff --git a/api/src/paths/alert/{alertId}/index.ts b/api/src/paths/alert/{alertId}/index.ts new file mode 100644 index 0000000000..141f52155e --- /dev/null +++ b/api/src/paths/alert/{alertId}/index.ts @@ -0,0 +1,317 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { systemAlertGetSchema, systemAlertPutSchema } from '../../../openapi/schemas/alert'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { AlertService } from '../../../services/alert-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/alert/{alertId}/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getAlertById() +]; + +GET.apiDoc = { + description: 'Gets a specific system alert.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to get' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: systemAlertGetSchema + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get a specific system alert by its id + * + * @returns {RequestHandler} + */ +export function getAlertById(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getAlertById' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + + const alertService = new AlertService(connection); + + const alert = await alertService.getAlertById(alertId); + + await connection.commit(); + + return res.status(200).json(alert); + } catch (error) { + defaultLog.error({ label: 'getAlertById', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const PUT: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + updateAlert() +]; + +PUT.apiDoc = { + description: 'Update an alert by its id.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to update' + } + } + ], + requestBody: { + description: 'Alert put request object.', + required: true, + content: { + 'application/json': { + schema: systemAlertPutSchema + } + } + }, + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Updates a system alert by its id + * + * @returns {RequestHandler} + */ +export function updateAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'updateAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + const alert = req.body; + + const alertService = new AlertService(connection); + + const id = await alertService.updateAlert({ ...alert, alert_id: alertId }); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'updateAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +export const DELETE: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteAlert() +]; + +DELETE.apiDoc = { + description: 'Delete an alert by its id.', + tags: ['alerts'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + required: true, + name: 'alertId', + schema: { + type: 'string', + description: 'Id of an alert to delete' + } + } + ], + responses: { + 200: { + description: 'System alert response object', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['alert_id'], + properties: { + alert_id: { + type: 'number' + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Deletes a system alert by its id + * + * @returns {RequestHandler} + */ +export function deleteAlert(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'deleteAlert' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const alertId = Number(req.params.alertId); + + const alertService = new AlertService(connection); + + const id = await alertService.deleteAlert(alertId); + + await connection.commit(); + + return res.status(200).json({ alert_id: id }); + } catch (error) { + defaultLog.error({ label: 'deleteAlert', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/codes.ts b/api/src/paths/codes.ts index eebad437bc..ebbf556be8 100644 --- a/api/src/paths/codes.ts +++ b/api/src/paths/codes.ts @@ -37,7 +37,8 @@ GET.apiDoc = { 'site_selection_strategies', 'survey_progress', 'method_response_metrics', - 'attractants' + 'attractants', + 'vantages' ], properties: { management_action_type: { @@ -382,6 +383,48 @@ GET.apiDoc = { } } } + }, + alert_types: { + type: 'array', + description: 'Alert type options for system administrators managing alert messages.', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'description'], + properties: { + id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + vantages: { + type: 'array', + description: 'Vantages that vantage modes belong to.', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'name', 'description'], + properties: { + id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } } } } diff --git a/api/src/paths/funding-sources/index.ts b/api/src/paths/funding-sources/index.ts index c1e351df47..8feca95853 100644 --- a/api/src/paths/funding-sources/index.ts +++ b/api/src/paths/funding-sources/index.ts @@ -183,6 +183,7 @@ POST.apiDoc = { ], requestBody: { description: 'Funding source post request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/funding-sources/{fundingSourceId}.ts b/api/src/paths/funding-sources/{fundingSourceId}.ts index 048e5ffa80..2627056e06 100644 --- a/api/src/paths/funding-sources/{fundingSourceId}.ts +++ b/api/src/paths/funding-sources/{fundingSourceId}.ts @@ -231,6 +231,7 @@ PUT.apiDoc = { ], requestBody: { description: 'Funding source put request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/gcnotify/send.ts b/api/src/paths/gcnotify/send.ts index 938cf5a44c..0894259121 100644 --- a/api/src/paths/gcnotify/send.ts +++ b/api/src/paths/gcnotify/send.ts @@ -33,6 +33,7 @@ POST.apiDoc = { ], requestBody: { description: 'Send notification to given recipient', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/markdown/index.test.ts b/api/src/paths/markdown/index.test.ts new file mode 100644 index 0000000000..68f6781898 --- /dev/null +++ b/api/src/paths/markdown/index.test.ts @@ -0,0 +1,93 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMarkdownByTypeName } from '.'; +import * as db from '../../database/db'; +import { HTTPError } from '../../errors/http-error'; +import { MarkdownService } from '../../services/markdown-service'; +import { KeycloakUserInformation } from '../../utils/keycloak-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../__mocks__/db'; + +chai.use(sinonChai); + +describe('getMarkdown', () => { + afterEach(() => { + sinon.restore(); + }); + + it('successfully retrieves markdown', async () => { + const mockMarkdownResponse = { + markdown_id: 1, + markdown_type_id: 1, + data: 'Sample markdown content', + participated: false + }; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const getMarkdownStub = sinon + .stub(MarkdownService.prototype, 'getMarkdownByTypeName') + .resolves(mockMarkdownResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { typeName: 'help' }; + mockReq.keycloak_token = {} as KeycloakUserInformation; + + const requestHandler = getMarkdownByTypeName(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(getMarkdownStub).to.have.been.calledOnceWith({ + markdown_type_name: 'help', + system_user_id: 20 + }); + expect(mockRes.jsonValue.markdown).to.eql(mockMarkdownResponse); + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const getMarkdownStub = sinon + .stub(MarkdownService.prototype, 'getMarkdownByTypeName') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { typeName: 'help' }; + mockReq.keycloak_token = {} as KeycloakUserInformation; + + const requestHandler = getMarkdownByTypeName(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail('Expected error was not thrown'); + } catch (actualError) { + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(getMarkdownStub).to.have.been.calledOnceWith({ + markdown_type_name: 'help', + system_user_id: 20 + }); + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as HTTPError).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/markdown/index.ts b/api/src/paths/markdown/index.ts new file mode 100644 index 0000000000..a525f96d3a --- /dev/null +++ b/api/src/paths/markdown/index.ts @@ -0,0 +1,106 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getDBConnection } from '../../database/db'; +import { markdownSchema } from '../../openapi/schemas/markdown'; +import { authorizeRequestHandler } from '../../request-handlers/security/authorization'; +import { MarkdownService } from '../../services/markdown-service'; +import { getLogger } from '../../utils/logger'; + +const defaultLog = getLogger('paths/markdown/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + getMarkdownByTypeName() +]; + +GET.apiDoc = { + description: 'Gets a markdown record to display in a help dialog.', + tags: ['markdown'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'typeName', + description: 'The name of a markdown type to retrieve the latest markdown record for', + required: true, + schema: { + type: 'string' + } + } + ], + responses: { + 200: { + description: 'Markdown response object.', + content: { + 'application/json': { + schema: markdownSchema + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get the latest markdown text for a given markdown type + * + * @returns {RequestHandler} + */ +export function getMarkdownByTypeName(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'getMarkdownByTypeName' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const markdownTypeName = req.query.typeName as string; + + const markdownService = new MarkdownService(connection); + + const markdown = await markdownService.getMarkdownByTypeName({ + markdown_type_name: markdownTypeName, + system_user_id: systemUserId + }); + + await connection.commit(); + + return res.status(200).json({ markdown }); + } catch (error) { + defaultLog.error({ label: 'getMarkdown', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/markdown/{markdownId}/index.test.ts b/api/src/paths/markdown/{markdownId}/index.test.ts new file mode 100644 index 0000000000..9cd152a4eb --- /dev/null +++ b/api/src/paths/markdown/{markdownId}/index.test.ts @@ -0,0 +1,117 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../database/db'; +import { MarkdownService } from '../../../services/markdown-service'; +import { KeycloakUserInformation } from '../../../utils/keycloak-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; +import { scoreMarkdown } from './index'; + +chai.use(sinonChai); + +describe('scoreMarkdown', () => { + afterEach(() => { + sinon.restore(); + }); + + it('successfully submits a score for a markdown record', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const handleScoreChangeStub = sinon.stub(MarkdownService.prototype, 'handleScoreChange').resolves(10); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { markdownId: '1' }; + mockReq.body = { score: -1 }; + mockReq.keycloak_token = {} as KeycloakUserInformation; + + const requestHandler = scoreMarkdown(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(handleScoreChangeStub).to.have.been.calledOnceWith(1, 20, -1); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledOnce; + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('returns a 500 error if the user has already scored the markdown record', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const handleScoreChangeStub = sinon.stub(MarkdownService.prototype, 'handleScoreChange').resolves(null); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { markdownId: '1' }; + mockReq.body = { score: -1 }; + mockReq.keycloak_token = {} as KeycloakUserInformation; + + const requestHandler = scoreMarkdown(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(handleScoreChangeStub).to.have.been.calledOnceWith(1, 20, -1); + + expect(mockRes.status).to.have.been.calledWith(500); + expect(mockRes.json).to.have.been.calledOnce; + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('handles errors gracefully', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + rollback: sinon.stub(), + release: sinon.stub(), + systemUserId: () => 20 + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const handleScoreChangeStub = sinon + .stub(MarkdownService.prototype, 'handleScoreChange') + .rejects(new Error('a test error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.params = { markdownId: '1' }; + mockReq.body = { score: 1 }; + mockReq.keycloak_token = {} as KeycloakUserInformation; + + const requestHandler = scoreMarkdown(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail('Expected error was not thrown'); + } catch (actualError) { + expect(handleScoreChangeStub).to.have.been.calledOnceWith(1, 20, 1); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + + expect((actualError as Error).message).to.equal('a test error'); + } + }); +}); diff --git a/api/src/paths/markdown/{markdownId}/index.ts b/api/src/paths/markdown/{markdownId}/index.ts new file mode 100644 index 0000000000..c3e6d47734 --- /dev/null +++ b/api/src/paths/markdown/{markdownId}/index.ts @@ -0,0 +1,124 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getDBConnection } from '../../../database/db'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { MarkdownService } from '../../../services/markdown-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/markdown/{markdownId}/index'); + +export const POST: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + scoreMarkdown() +]; + +POST.apiDoc = { + description: 'Submits a score for a markdown record', + tags: ['markdown'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'markdownId', + description: 'Primary key of a markdown record to submit a score for', + required: true, + schema: { + type: 'integer', + minimum: 1 + } + } + ], + requestBody: { + description: 'Score for a markdown record.', + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + required: ['score'], + properties: { + score: { + type: 'number', + description: 'Score to add to the markdown record', + enum: [-1, 1] + } + } + } + } + } + }, + responses: { + 200: { + description: 'Markdown score response object.' + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Apply a score to a markdown record + * + * @returns {RequestHandler} + */ +export function scoreMarkdown(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'scoreMarkdown' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const markdownId = Number(req.params.markdownId); + const score = req.body.score; + + const markdownService = new MarkdownService(connection); + + const success = await markdownService.handleScoreChange(markdownId, systemUserId, score); + + await connection.commit(); + + // If the user has already scored, return a 500 error + if (!success) { + return res.status(500).json(); + } + + return res.status(200).json(); + } catch (error) { + defaultLog.error({ label: 'scoreMarkdown', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/observation/index.test.ts b/api/src/paths/observation/index.test.ts index c3178ed4b4..c366134e4c 100644 --- a/api/src/paths/observation/index.test.ts +++ b/api/src/paths/observation/index.test.ts @@ -42,6 +42,7 @@ describe('findObservations', () => { observation_subcount_id: 9, subcount: 5, observation_subcount_sign_id: 1, + comment: 'comment', qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], @@ -103,7 +104,8 @@ describe('findObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }); expect(mockRes.jsonValue.pagination).not.to.be.null; @@ -133,6 +135,7 @@ describe('findObservations', () => { observation_subcount_id: 9, subcount: 5, observation_subcount_sign_id: 1, + comment: 'comment', qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], diff --git a/api/src/paths/observation/index.ts b/api/src/paths/observation/index.ts index f90f83be54..c7c21917c5 100644 --- a/api/src/paths/observation/index.ts +++ b/api/src/paths/observation/index.ts @@ -209,7 +209,8 @@ export function findObservations(): RequestHandler { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: makePaginationResponse(observationsTotalCount, paginationOptions) }; diff --git a/api/src/paths/project/{projectId}/attachments/report/upload.ts b/api/src/paths/project/{projectId}/attachments/report/upload.ts index 150ebc1847..180b060dc0 100644 --- a/api/src/paths/project/{projectId}/attachments/report/upload.ts +++ b/api/src/paths/project/{projectId}/attachments/report/upload.ts @@ -50,6 +50,7 @@ POST.apiDoc = { ], requestBody: { description: 'Attachment upload post request object.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/attachments/upload.ts b/api/src/paths/project/{projectId}/attachments/upload.ts index aecadf410d..4f578d8a2b 100644 --- a/api/src/paths/project/{projectId}/attachments/upload.ts +++ b/api/src/paths/project/{projectId}/attachments/upload.ts @@ -51,6 +51,7 @@ POST.apiDoc = { ], requestBody: { description: 'Attachment upload post request object.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts index 16c2dbdb05..ab68b15d71 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/delete.ts @@ -55,6 +55,7 @@ POST.apiDoc = { ], requestBody: { description: 'Current attachment type for project attachment.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.ts b/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.ts index c93706c7f6..517544c018 100644 --- a/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.ts +++ b/api/src/paths/project/{projectId}/attachments/{attachmentId}/metadata/update.ts @@ -62,6 +62,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/participants/index.ts b/api/src/paths/project/{projectId}/participants/index.ts index e07c97b0a1..19fdc04512 100644 --- a/api/src/paths/project/{projectId}/participants/index.ts +++ b/api/src/paths/project/{projectId}/participants/index.ts @@ -109,6 +109,7 @@ export function getParticipants(): RequestHandler { return res.status(200).json(result); } catch (error) { defaultLog.error({ label: 'getAllProjectParticipantsSQL', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); @@ -155,6 +156,7 @@ POST.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { @@ -257,6 +259,7 @@ export function postProjectParticipants(): RequestHandler { return res.status(200).send(); } catch (error) { defaultLog.error({ label: 'postProjectParticipants', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/participants/self.ts b/api/src/paths/project/{projectId}/participants/self.ts index fefb2b6aec..a42f4a9f73 100644 --- a/api/src/paths/project/{projectId}/participants/self.ts +++ b/api/src/paths/project/{projectId}/participants/self.ts @@ -91,6 +91,7 @@ export function getSelf(): RequestHandler { return res.status(200).json(result); } catch (error) { defaultLog.error({ label: 'getAllProjectParticipantsSQL', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/participants/{projectParticipationId}.ts b/api/src/paths/project/{projectId}/participants/{projectParticipationId}.ts index 79255de1b2..a319ee9850 100644 --- a/api/src/paths/project/{projectId}/participants/{projectParticipationId}.ts +++ b/api/src/paths/project/{projectId}/participants/{projectParticipationId}.ts @@ -57,6 +57,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/create.ts b/api/src/paths/project/{projectId}/survey/create.ts index 23fb747756..7f5a3aed74 100644 --- a/api/src/paths/project/{projectId}/survey/create.ts +++ b/api/src/paths/project/{projectId}/survey/create.ts @@ -61,6 +61,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey post request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/index.ts b/api/src/paths/project/{projectId}/survey/index.ts index 0a25ea3112..b3c2b3b4e2 100644 --- a/api/src/paths/project/{projectId}/survey/index.ts +++ b/api/src/paths/project/{projectId}/survey/index.ts @@ -165,6 +165,7 @@ export function getSurveys(): RequestHandler { return res.status(200).json(response); } catch (error) { defaultLog.error({ label: 'getSurveys', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.ts index b058a597c7..fdf5847dfa 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/report/upload.ts @@ -59,6 +59,7 @@ POST.apiDoc = { ], requestBody: { description: 'Attachment upload post request object.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.ts index fa99abc18c..4c93e7440b 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/telemetry/index.ts @@ -65,6 +65,7 @@ POST.apiDoc = { ], requestBody: { description: 'Telemetry device credential file upload post request object.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.ts index 98ef6123a3..4a9087a5fb 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/upload.ts @@ -52,6 +52,7 @@ POST.apiDoc = { ], requestBody: { description: 'Attachment upload post request object.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts index ca50846f80..b7fdac3d68 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/delete.ts @@ -61,6 +61,7 @@ POST.apiDoc = { ], requestBody: { description: 'Current attachment type for survey attachment.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts index 5bd8c215a6..beb81d9482 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/getSignedUrl.ts @@ -1,10 +1,15 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; -import { ATTACHMENT_TYPE, TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE } from '../../../../../../../constants/attachments'; +import { + ATTACHMENT_TYPE, + CRITTER_CAPTURE_ATTACHMENT_TYPE, + TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE +} from '../../../../../../../constants/attachments'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { AttachmentService } from '../../../../../../../services/attachment-service'; +import { CritterAttachmentService } from '../../../../../../../services/critter-attachment-service'; import { getS3SignedURL } from '../../../../../../../utils/file-utils'; import { getLogger } from '../../../../../../../utils/logger'; @@ -74,7 +79,14 @@ GET.apiDoc = { name: 'attachmentType', schema: { type: 'string', - enum: ['Report', 'KeyX', 'Cfg', 'Other'] + enum: [ + ATTACHMENT_TYPE.REPORT, + ATTACHMENT_TYPE.OTHER, + TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG, + TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX, + CRITTER_CAPTURE_ATTACHMENT_TYPE.CAPTURE, + CRITTER_CAPTURE_ATTACHMENT_TYPE.MORTALITY + ] }, required: true } @@ -118,6 +130,10 @@ export function getSurveyAttachmentSignedURL(): RequestHandler { req_body: req.body }); + const surveyId = Number(req.params.surveyId); + const attachmentId = Number(req.params.attachmentId); + const attachmentType = req.query.attachmentType; + const connection = getDBConnection(req.keycloak_token); try { @@ -126,26 +142,25 @@ export function getSurveyAttachmentSignedURL(): RequestHandler { let s3Key; const attachmentService = new AttachmentService(connection); + const critterAttachmentService = new CritterAttachmentService(connection); - if (req.query.attachmentType === ATTACHMENT_TYPE.REPORT) { - s3Key = await attachmentService.getSurveyReportAttachmentS3Key( - Number(req.params.surveyId), - Number(req.params.attachmentId) - ); - } else if ( - req.query.attachmentType === TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX || - req.query.attachmentType === TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG - ) { - s3Key = await attachmentService.getSurveyTelemetryCredentialAttachmentS3Key( - Number(req.params.surveyId), - Number(req.params.attachmentId) - ); - } else { - s3Key = await attachmentService.getSurveyAttachmentS3Key( - Number(req.params.surveyId), - Number(req.params.attachmentId) - ); + switch (attachmentType) { + case CRITTER_CAPTURE_ATTACHMENT_TYPE.CAPTURE: + s3Key = await critterAttachmentService.getCritterCaptureAttachmentS3Key(surveyId, attachmentId); + break; + case ATTACHMENT_TYPE.REPORT: + s3Key = await attachmentService.getSurveyReportAttachmentS3Key(surveyId, attachmentId); + break; + case TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.KEYX: + s3Key = await attachmentService.getSurveyTelemetryCredentialAttachmentS3Key(surveyId, attachmentId); + break; + case TELEMETRY_CREDENTIAL_ATTACHMENT_TYPE.CFG: + s3Key = await attachmentService.getSurveyTelemetryCredentialAttachmentS3Key(surveyId, attachmentId); + break; + default: + s3Key = await attachmentService.getSurveyAttachmentS3Key(surveyId, attachmentId); } + await connection.commit(); const s3SignedUrl = await getS3SignedURL(s3Key); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.ts index 01fbc1077d..114328287f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/attachments/{attachmentId}/metadata/update.ts @@ -68,6 +68,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/captures/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/captures/import.ts index ec6142349d..68237ed887 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/captures/import.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/captures/import.ts @@ -63,6 +63,7 @@ POST.apiDoc = { ], requestBody: { description: 'Critterbase Captures CSV import file.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/delete.ts index a20ba8ef23..a37907c4d3 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/delete.ts @@ -47,6 +47,7 @@ POST.apiDoc = { ], requestBody: { description: 'Array of survey critter Ids to be deleted.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts index f31ba7d465..6c24e5b70f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.test.ts @@ -1,19 +1,20 @@ import { expect } from 'chai'; import sinon from 'sinon'; import * as db from '../../../../../../database/db'; -import * as strategy from '../../../../../../services/import-services/import-csv'; +import { ImportCrittersService } from '../../../../../../services/import-services/critter/import-critters-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; -import { importCsv } from './import'; +import { importCritterCSV } from './import'; describe('importCsv', () => { afterEach(() => { sinon.restore(); }); - it('returns imported critters', async () => { + it('status 200 when successful', async () => { const mockDBConnection = getMockDBConnection({ open: sinon.stub(), commit: sinon.stub(), release: sinon.stub() }); const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); - const mockImportCSV = sinon.stub(strategy, 'importCSV').resolves([1, 2]); + + const importCSVWorksheetStub = sinon.stub(ImportCrittersService.prototype, 'importCSVWorksheet'); const mockFile = { originalname: 'test.csv', mimetype: 'test.csv', buffer: Buffer.alloc(1) } as Express.Multer.File; @@ -22,7 +23,7 @@ describe('importCsv', () => { mockReq.files = [mockFile]; mockReq.params.surveyId = '1'; - const requestHandler = importCsv(); + const requestHandler = importCritterCSV(); await requestHandler(mockReq, mockRes, mockNext); @@ -30,9 +31,10 @@ describe('importCsv', () => { expect(getDBConnectionStub).to.have.been.calledOnce; - expect(mockImportCSV).to.have.been.calledOnce; + expect(importCSVWorksheetStub).to.have.been.calledOnce; - expect(mockRes.json).to.have.been.calledOnceWithExactly({ survey_critter_ids: [1, 2] }); + expect(mockRes.status).to.have.been.calledOnceWithExactly(200); + expect(mockRes.send).to.have.been.calledOnceWithExactly(); expect(mockDBConnection.commit).to.have.been.calledOnce; expect(mockDBConnection.release).to.have.been.calledOnce; diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts index 01ef07460a..f3b83117fa 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/import.ts @@ -4,11 +4,11 @@ import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/rol import { getDBConnection } from '../../../../../../database/db'; import { csvFileSchema } from '../../../../../../openapi/schemas/file'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; -import { ImportCrittersStrategy } from '../../../../../../services/import-services/critter/import-critters-strategy'; -import { importCSV } from '../../../../../../services/import-services/import-csv'; +import { ImportCrittersService } from '../../../../../../services/import-services/critter/import-critters-service'; import { getLogger } from '../../../../../../utils/logger'; import { parseMulterFile } from '../../../../../../utils/media/media-utils'; import { getFileFromRequest } from '../../../../../../utils/request'; +import { constructXLSXWorkbook, getDefaultWorksheet } from '../../../../../../utils/xlsx-utils/worksheet-utils'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/critters/import'); @@ -28,7 +28,7 @@ export const POST: Operation = [ ] }; }), - importCsv() + importCritterCSV() ]; POST.apiDoc = { @@ -61,6 +61,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey critters csv file to import', + required: true, content: { 'multipart/form-data': { schema: { @@ -82,25 +83,7 @@ POST.apiDoc = { }, responses: { 200: { - description: 'Import OK', - content: { - 'application/json': { - schema: { - type: 'object', - additionalProperties: false, - required: ['survey_critter_ids'], - properties: { - survey_critter_ids: { - type: 'array', - items: { - type: 'integer', - minimum: 1 - } - } - } - } - } - } + description: 'Import OK' }, 400: { $ref: '#/components/responses/400' @@ -125,26 +108,26 @@ POST.apiDoc = { * * @return {*} {RequestHandler} */ -export function importCsv(): RequestHandler { +export function importCritterCSV(): RequestHandler { return async (req, res) => { const surveyId = Number(req.params.surveyId); const rawFile = getFileFromRequest(req); const connection = getDBConnection(req.keycloak_token); + const mediaFile = parseMulterFile(rawFile); + const worksheet = getDefaultWorksheet(constructXLSXWorkbook(mediaFile)); + try { await connection.open(); - // Critter CSV import strategy - child of CSVImportStrategy - const importCsvCritters = new ImportCrittersStrategy(connection, surveyId); - - const surveyCritterIds = await importCSV(parseMulterFile(rawFile), importCsvCritters); + const importService = new ImportCrittersService(connection, worksheet, surveyId); - defaultLog.info({ label: 'importCritterCsv', message: 'result', survey_critter_ids: surveyCritterIds }); + await importService.importCSVWorksheet(); await connection.commit(); - return res.status(200).json({ survey_critter_ids: surveyCritterIds }); + return res.status(200).send(); } catch (error) { defaultLog.error({ label: 'importCritterCsv', message: 'error', error }); await connection.rollback(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts index 5c4ca54512..f07eacfe48 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/index.ts @@ -140,6 +140,7 @@ POST.apiDoc = { ], requestBody: { description: 'Critterbase create critter request object', + required: true, content: { 'application/json': { schema: { @@ -239,6 +240,8 @@ export function getCrittersFromSurvey(): RequestHandler { const critterbaseCritters = await surveyService.critterbaseService.getMultipleCrittersByIds(critterIds); + await connection.commit(); + const response = []; // For all SIMS critters diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/markings/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/markings/import.ts index 31336e8c0d..ae13127315 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/markings/import.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/markings/import.ts @@ -63,6 +63,7 @@ POST.apiDoc = { ], requestBody: { description: 'Critterbase Markings CSV import file.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/measurements/import.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/measurements/import.ts index affd0813a4..896b859101 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/measurements/import.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/measurements/import.ts @@ -63,6 +63,7 @@ POST.apiDoc = { ], requestBody: { description: 'Critterbase Measurements CSV import file.', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.test.ts new file mode 100644 index 0000000000..97da2f9659 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { deleteCritterCaptureAttachments } from '.'; +import * as db from '../../../../../../../../../../database/db'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import * as S3 from '../../../../../../../../../../utils/file-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../../../__mocks__/db'; + +describe('deleteCritterCaptureAttachments', () => { + afterEach(() => { + sinon.restore(); + }); + + it('deletes all attachments for a critter capture', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + rollback: sinon.stub() + }); + + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockFindAttachments = sinon.stub(CritterAttachmentService.prototype, 'findAllCritterCaptureAttachments'); + const mockDeleteAttachments = sinon.stub(CritterAttachmentService.prototype, 'deleteCritterCaptureAttachments'); + const mockBulkDeleteFilesFromS3 = sinon.stub(S3, 'bulkDeleteFilesFromS3'); + + mockFindAttachments.resolves([{ critter_capture_attachment_id: 1, key: 'DELETE_S3_KEY' }] as any[]); + mockDeleteAttachments.resolves(['DELETE_S3_KEY']); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '2', + critterId: '3', + critterbaseCaptureId: '123e4567-e89b-12d3-a456-426614174000' + }; + + const requestHandler = deleteCritterCaptureAttachments(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(mockFindAttachments).to.have.been.calledOnceWithExactly(2, '123e4567-e89b-12d3-a456-426614174000'); + expect(mockDeleteAttachments).to.have.been.calledOnceWithExactly(2, [1]); + + expect(mockBulkDeleteFilesFromS3).to.have.been.calledOnceWithExactly(['DELETE_S3_KEY']); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.send).to.have.been.calledWith(); + + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.not.have.been.called; + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.ts new file mode 100644 index 0000000000..86dd273bbf --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/index.ts @@ -0,0 +1,136 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../../../database/db'; +import { authorizeRequestHandler } from '../../../../../../../../../../request-handlers/security/authorization'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import { bulkDeleteFilesFromS3 } from '../../../../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../../../../utils/logger'; + +const defaultLog = getLogger( + '/api/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments' +); + +export const DELETE: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + deleteCritterCaptureAttachments() +]; + +DELETE.apiDoc = { + description: 'Delete all attachments for a critter capture.', + tags: ['attachment'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'critterId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'critterbaseCaptureId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + responses: { + 200: { + description: 'Delete OK' + }, + 401: { + $ref: '#/components/responses/401' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Delete all attachments for a critter capture. + * + * @returns {RequestHandler} + */ +export function deleteCritterCaptureAttachments(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + const critterbaseCaptureId = req.params.critterbaseCaptureId; + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const critterAttachmentService = new CritterAttachmentService(connection); + + // Get all attachments for the critter capture + const attachments = await critterAttachmentService.findAllCritterCaptureAttachments( + surveyId, + critterbaseCaptureId + ); + + // Get the S3 keys and attachmentIds for the attachments + const s3Keys = attachments.map((attachment) => attachment.key); + const attachmentIds = attachments.map((attachment) => attachment.critter_capture_attachment_id); + + // Delete the attachments from the database + await critterAttachmentService.deleteCritterCaptureAttachments(surveyId, attachmentIds); + + // Delete the attachments from S3 + await bulkDeleteFilesFromS3(s3Keys); + + await connection.commit(); + + return res.status(200).send(); + } catch (error) { + defaultLog.error({ label: 'deleteCritterCaptureAttachments', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.test.ts new file mode 100644 index 0000000000..06c36e58bd --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.test.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as db from '../../../../../../../../../../database/db'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import * as S3 from '../../../../../../../../../../utils/file-utils'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../../../../__mocks__/db'; +import { uploadCaptureAttachments } from './upload'; + +describe('uploadCaptureAttachments', () => { + afterEach(() => { + sinon.restore(); + }); + + const mockFile = { + fieldname: 'media', + originalname: 'test.txt', + encoding: '7bit', + mimetype: 'text/plain', + size: 1024, + buffer: Buffer.from('test') + }; + + it('creates attachments and deletes any attachments from a list of ids', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + rollback: sinon.stub() + }); + + const getDBConnectionStub = sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + const mockUpsertAttachment = sinon.stub(CritterAttachmentService.prototype, 'upsertCritterCaptureAttachment'); + const mockDeleteAttachments = sinon.stub(CritterAttachmentService.prototype, 'deleteCritterCaptureAttachments'); + const mockS3UploadFileToS3 = sinon.stub(S3, 'uploadFileToS3'); + const mockS3GenerateS3FileKey = sinon.stub(S3, 'generateS3FileKey').returns('S3KEY'); + const mockBulkDeleteFilesFromS3 = sinon.stub(S3, 'bulkDeleteFilesFromS3'); + + mockUpsertAttachment.resolves({ critter_capture_attachment_id: 1, key: 'S3KEY' }); + mockDeleteAttachments.resolves(['DELETE_S3_KEY']); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.files = [mockFile as Express.Multer.File]; + mockReq.body = { + delete_ids: ['1', '2'] + }; + mockReq.params = { + projectId: '1', + surveyId: '2', + critterId: '3', + critterbaseCaptureId: '123e4567-e89b-12d3-a456-426614174000' + }; + + const requestHandler = uploadCaptureAttachments(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getDBConnectionStub).to.have.been.calledOnce; + expect(mockDBConnection.open).to.have.been.calledOnce; + + expect(mockDeleteAttachments).to.have.been.calledOnceWithExactly(2, [1, 2]); + expect(mockBulkDeleteFilesFromS3).to.have.been.calledOnceWithExactly(['DELETE_S3_KEY']); + + expect(mockS3GenerateS3FileKey).to.have.been.calledOnceWithExactly({ + projectId: 1, + surveyId: 2, + critterId: 3, + folder: 'captures', + critterbaseCaptureId: '123e4567-e89b-12d3-a456-426614174000', + fileName: 'test.txt' + }); + + expect(mockUpsertAttachment).to.have.been.calledOnceWithExactly({ + critter_id: 3, + critterbase_capture_id: '123e4567-e89b-12d3-a456-426614174000', + file_name: 'test.txt', + file_size: 1024, + key: 'S3KEY' + }); + + expect(mockS3UploadFileToS3).to.have.been.calledWith(mockFile, 'S3KEY'); + + expect(mockRes.status).to.have.been.calledWith(200); + expect(mockRes.json).to.have.been.calledWith({ attachment_ids: [1] }); + + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.not.have.been.called; + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.ts new file mode 100644 index 0000000000..d5549f226e --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload.ts @@ -0,0 +1,214 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../../../../../database/db'; +import { fileSchema } from '../../../../../../../../../../openapi/schemas/file'; +import { authorizeRequestHandler } from '../../../../../../../../../../request-handlers/security/authorization'; +import { CritterAttachmentService } from '../../../../../../../../../../services/critter-attachment-service'; +import { + bulkDeleteFilesFromS3, + generateS3FileKey, + uploadFileToS3 +} from '../../../../../../../../../../utils/file-utils'; +import { getLogger } from '../../../../../../../../../../utils/logger'; + +const defaultLog = getLogger( + '/api/project/{projectId}/survey/{surveyId}/critters/{critterId}/captures/{critterbaseCaptureId}/attachments/upload' +); + +export const POST: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + uploadCaptureAttachments() +]; + +POST.apiDoc = { + description: 'Upload a Critter capture-specific attachment.', + tags: ['attachment'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'critterId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'critterbaseCaptureId', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + requestBody: { + description: 'Attachment upload post request object.', + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + required: ['media'], + additionalProperties: false, + properties: { + media: { + description: 'Uploaded Capture attachments.', + type: 'array', + items: fileSchema + }, + delete_ids: { + description: 'Critter Capture Attachment IDs to delete.', + type: 'array', + items: { + type: 'string', + format: 'integer' + } + } + } + } + } + } + }, + responses: { + 200: { + description: 'Successfull upload response.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['attachment_ids'], + additionalProperties: false, + properties: { + attachment_ids: { + description: 'The IDs of the capture attachments that were uploaded.', + type: 'array', + items: { + type: 'integer', + minItems: 1 + } + } + } + } + } + } + }, + 401: { + $ref: '#/components/responses/401' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Uploads any media in the request to S3, adding their keys to the request. + * Optionally deletes any attachments flagged for deletion. + * + * @returns {RequestHandler} + */ +export function uploadCaptureAttachments(): RequestHandler { + return async (req, res) => { + const rawMediaFiles = req.files as Express.Multer.File[]; + const deleteIds: number[] = req.body.delete_ids?.map(Number) ?? []; + const projectId = Number(req.params.projectId); + const surveyId = Number(req.params.surveyId); + const critterId = Number(req.params.critterId); + const critterbaseCaptureId = req.params.critterbaseCaptureId; + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const critterAttachmentService = new CritterAttachmentService(connection); + + // Delete any flagged attachments + if (deleteIds.length) { + // Delete the attachments from the database and get the S3 keys + const s3Keys = await critterAttachmentService.deleteCritterCaptureAttachments(surveyId, deleteIds); + // Bulk delete the files from S3 + await bulkDeleteFilesFromS3(s3Keys); + } + + // Upload each file to S3 and store the file details in the database + const uploadPromises = rawMediaFiles.map(async (file) => { + // Generate the S3 key for the file - used only on new inserts + const s3Key = generateS3FileKey({ + projectId: projectId, + surveyId: surveyId, + critterId: critterId, + folder: 'captures', + critterbaseCaptureId: critterbaseCaptureId, + fileName: file.originalname + }); + + // Store the file details in the database + const upsertResult = await critterAttachmentService.upsertCritterCaptureAttachment({ + critter_id: critterId, + critterbase_capture_id: critterbaseCaptureId, + file_name: file.originalname, + file_size: file.size, + key: s3Key + }); + + await uploadFileToS3(file, upsertResult.key); + + return upsertResult.critter_capture_attachment_id; + }); + + // In parallel, upload all the files to S3 and store the file details in the database + const attachmentIds = await Promise.all(uploadPromises); + + await connection.commit(); + + return res.status(200).json({ attachment_ids: attachmentIds }); + } catch (error) { + defaultLog.error({ label: 'uploadCaptureAttachments', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts index 9b8b30af5f..4e909336c5 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/index.ts @@ -61,6 +61,7 @@ POST.apiDoc = { ], requestBody: { description: 'Object with device information and associated captures to create a deployment', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.ts index 01afb217ee..e1e908f20f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/deployments/{deploymentId}/index.ts @@ -80,6 +80,7 @@ PATCH.apiDoc = { ], requestBody: { description: 'Specifies a deployment id and the new timerange to update it with.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts index 3c22b02747..80e0ca92c7 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/critters/{critterId}/index.ts @@ -6,6 +6,7 @@ import { HTTPError, HTTPErrorType } from '../../../../../../../errors/http-error import { bulkUpdateResponse, critterBulkRequestObject } from '../../../../../../../openapi/schemas/critter'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { getBctwUser } from '../../../../../../../services/bctw-service/bctw-service'; +import { CritterAttachmentService } from '../../../../../../../services/critter-attachment-service'; import { CritterbaseService, ICritterbaseUser } from '../../../../../../../services/critterbase-service'; import { SurveyCritterService } from '../../../../../../../services/survey-critter-service'; import { getLogger } from '../../../../../../../utils/logger'; @@ -51,6 +52,7 @@ PATCH.apiDoc = { ], requestBody: { description: 'Critterbase bulk patch request object', + required: true, content: { 'application/json': { schema: critterBulkRequestObject @@ -220,6 +222,7 @@ export function getCrittersFromSurvey(): RequestHandler { const surveyService = new SurveyCritterService(connection); const critterbaseService = new CritterbaseService(user); + const critterAttachmentService = new CritterAttachmentService(connection); const surveyCritter = await surveyService.getCritterById(surveyId, critterId); @@ -227,7 +230,13 @@ export function getCrittersFromSurvey(): RequestHandler { return res.status(404).json({ error: `Critter with id ${critterId} not found.` }); } - const critterbaseCritter = await critterbaseService.getCritter(surveyCritter.critterbase_critter_id); + // Get the attachments from SIMS table and the Critter from critterbase + const [atttachments, critterbaseCritter] = await Promise.all([ + critterAttachmentService.findAllCritterAttachments(surveyCritter.critter_id), + critterbaseService.getCritter(surveyCritter.critterbase_critter_id) + ]); + + await connection.commit(); if (!critterbaseCritter || critterbaseCritter.length === 0) { return res.status(404).json({ error: `Critter ${surveyCritter.critterbase_critter_id} not found.` }); @@ -237,12 +246,16 @@ export function getCrittersFromSurvey(): RequestHandler { ...surveyCritter, ...critterbaseCritter, critterbase_critter_id: surveyCritter.critterbase_critter_id, - critter_id: surveyCritter.critter_id + critter_id: surveyCritter.critter_id, + attachments: { + capture_attachments: atttachments.captureAttachments + // TODO: add mortality attachments + } }; return res.status(200).json(critterMapped); } catch (error) { - defaultLog.error({ label: 'createCritter', message: 'error', error }); + defaultLog.error({ label: 'getCritter', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts index 95f363c957..0a5b50ca65 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/delete.ts @@ -59,6 +59,7 @@ POST.apiDoc = { ], requestBody: { description: 'Array of one or more deployment IDs to delete.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts index 89739a04ae..5ce785b665 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/index.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; +import { DefaultDateFormat, DefaultTimeFormat } from '../../../../../../constants/dates'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; import { getDeploymentSchema } from '../../../../../../openapi/schemas/deployment'; @@ -128,6 +129,8 @@ export function getDeploymentsInSurvey(): RequestHandler { // Fetch deployments from the deployment service for the given surveyId const surveyDeployments = await deploymentService.getDeploymentsForSurveyId(surveyId); + await connection.commit(); + // Extract deployment IDs from survey deployments const deploymentIds = surveyDeployments.map((deployment) => deployment.bctw_deployment_id); @@ -202,16 +205,16 @@ export function getDeploymentsInSurvey(): RequestHandler { assignment_id: matchingBctwDeployments[0].assignment_id, collar_id: matchingBctwDeployments[0].collar_id, attachment_start_date: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultDateFormat) : null, attachment_start_time: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultTimeFormat) : null, attachment_end_date: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultDateFormat) : null, attachment_end_time: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultTimeFormat) : null, bctw_deployment_id: matchingBctwDeployments[0].deployment_id, device_id: matchingBctwDeployments[0].device_id, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts index d3848fa574..f4bfe664c1 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/deployments/{deploymentId}/index.ts @@ -2,6 +2,7 @@ import { AxiosError } from 'axios'; import dayjs from 'dayjs'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; +import { DefaultDateFormat, DefaultTimeFormat } from '../../../../../../../constants/dates'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../../database/db'; import { HTTP400 } from '../../../../../../../errors/http-error'; @@ -146,6 +147,8 @@ export function getDeploymentById(): RequestHandler { // Fetch deployments from the deployment service for the given surveyId const surveyDeployment = await deploymentService.getDeploymentById(deploymentId); + await connection.commit(); + // Return early if there are no deployments if (!surveyDeployment) { // Return 400 if the provided deployment ID does not exist @@ -209,16 +212,16 @@ export function getDeploymentById(): RequestHandler { assignment_id: matchingBctwDeployments[0].assignment_id, collar_id: matchingBctwDeployments[0].collar_id, attachment_start_date: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultDateFormat) : null, attachment_start_time: matchingBctwDeployments[0].attachment_start - ? dayjs(matchingBctwDeployments[0].attachment_start).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_start).format(DefaultTimeFormat) : null, attachment_end_date: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('YYYY-MM-DD') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultDateFormat) : null, attachment_end_time: matchingBctwDeployments[0].attachment_end - ? dayjs(matchingBctwDeployments[0].attachment_end).format('HH:mm:ss') + ? dayjs(matchingBctwDeployments[0].attachment_end).format(DefaultTimeFormat) : null, bctw_deployment_id: matchingBctwDeployments[0].deployment_id, device_id: matchingBctwDeployments[0].device_id, @@ -306,6 +309,7 @@ PUT.apiDoc = { ], requestBody: { description: 'Specifies a deployment id and the new timerange to update it with.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/export/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/export/index.ts index b417551e18..2592f233fd 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/export/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/export/index.ts @@ -63,6 +63,7 @@ POST.apiDoc = { ], requestBody: { description: 'Export SIMS data request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.ts index f0008aa54b..e005c93203 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/delete.ts @@ -49,6 +49,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey observation record data', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts index 4ea8c3b84f..c18190f846 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/environments/delete.ts @@ -57,6 +57,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey observation environment delete request body.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts index 744c7706a4..758016cbba 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.test.ts @@ -160,7 +160,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -190,7 +191,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 59, @@ -220,7 +222,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -248,7 +251,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 50, @@ -278,7 +282,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] } }); @@ -301,7 +306,8 @@ describe('getSurveyObservations', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 2, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts index cd9b34bf63..8bae38b00d 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/index.ts @@ -9,8 +9,11 @@ import { CritterbaseService, getCritterbaseUser } from '../../../../../../servic import { InsertUpdateObservations, ObservationService } from '../../../../../../services/observation-service'; import { ObservationSubCountEnvironmentService } from '../../../../../../services/observation-subcount-environment-service'; import { getLogger } from '../../../../../../utils/logger'; -import { ensureCompletePaginationOptions, makePaginationResponse } from '../../../../../../utils/pagination'; -import { ApiPaginationOptions } from '../../../../../../zod-schema/pagination'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../../../../utils/pagination'; const defaultLog = getLogger('/api/project/{projectId}/survey/{surveyId}/observation'); @@ -134,6 +137,7 @@ PUT.apiDoc = { ], requestBody: { description: 'Survey observation record data', + required: true, content: { 'application/json': { schema: { @@ -225,6 +229,7 @@ PUT.apiDoc = { required: [ 'subcount', 'observation_subcount_sign_id', + 'comment', 'qualitative_measurements', 'quantitative_measurements', 'qualitative_environments', @@ -244,6 +249,11 @@ PUT.apiDoc = { description: 'The observation subcount sign ID, indicating whether the subcount was a direct sighting, footprints, scat, etc.' }, + comment: { + type: 'string', + nullable: true, + description: 'A comment or note about the subcount' + }, subcount: { type: 'number', description: "The subcount record's count." @@ -368,19 +378,11 @@ export function getSurveyObservations(): RequestHandler { const surveyId = Number(req.params.surveyId); defaultLog.debug({ label: 'getSurveyObservations', surveyId }); - const page: number | undefined = req.query.page ? Number(req.query.page) : undefined; - const limit: number | undefined = req.query.limit ? Number(req.query.limit) : undefined; - const order: 'asc' | 'desc' | undefined = req.query.order ? (String(req.query.order) as 'asc' | 'desc') : undefined; - - const sortQuery: string | undefined = req.query.sort ? String(req.query.sort) : undefined; - let sort = sortQuery; - - if (sortQuery && samplingSiteSortingColumnName[sortQuery]) { - sort = samplingSiteSortingColumnName[sortQuery]; + const paginationOptions = makePaginationOptionsFromRequest(req); + if (paginationOptions.sort && samplingSiteSortingColumnName[paginationOptions.sort]) { + paginationOptions.sort = samplingSiteSortingColumnName[paginationOptions.sort]; } - const paginationOptions: Partial = { page, limit, order, sort }; - const connection = getDBConnection(req.keycloak_token); try { @@ -394,6 +396,8 @@ export function getSurveyObservations(): RequestHandler { ensureCompletePaginationOptions(paginationOptions) ); + await connection.commit(); + const observationCount = observationData.supplementaryObservationData.observationCount; return res.status(200).json({ diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/delete.ts index 59894c1779..66593f36d7 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/delete.ts @@ -57,6 +57,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey observation measurement delete request body.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/index.ts index c0e885ce6b..73e0c6ccbb 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/measurements/index.ts @@ -122,6 +122,8 @@ export function getSurveyObservationMeasurements(): RequestHandler { const observationData = await subcountService.getMeasurementTypeDefinitionsForSurvey(surveyId); + await connection.commit(); + return res.status(200).json(observationData); } catch (error) { defaultLog.error({ label: 'getSurveyObservationMeasurements', message: 'error', error }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts index d4cc63c64a..ab1516617e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/process.ts @@ -49,6 +49,7 @@ POST.apiDoc = { ], requestBody: { description: 'Request body', + required: true, content: { 'application/json': { schema: { @@ -67,7 +68,7 @@ POST.apiDoc = { surveySamplePeriodId: { type: 'integer', description: - 'The optional ID of a survey sample period to associate the parsed observation records with.' + 'The optional ID of a survey sample period to associate the parsed observation records with. This is used when uploading all observations to a specific sample period, not when each record is for a different sample period.' } } } diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts index 1492421197..f917c6e264 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/spatial.ts @@ -1,4 +1,3 @@ -import { SchemaObject } from 'ajv'; import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; @@ -32,123 +31,6 @@ export const GET: Operation = [ getSurveyObservationsGeometry() ]; -export const surveyObservationsSupplementaryData: SchemaObject = { - type: 'object', - additionalProperties: false, - required: ['observationCount'], - properties: { - observationCount: { - type: 'integer', - minimum: 0 - }, - measurementColumns: { - type: 'array', - items: { - anyOf: [ - { - description: 'A quantitative (number) measurement, with possible min/max constraint.', - type: 'object', - additionalProperties: false, - required: [ - 'itis_tsn', - 'taxon_measurement_id', - 'measurement_name', - 'measurement_desc', - 'min_value', - 'max_value', - 'unit' - ], - properties: { - itis_tsn: { - type: 'number', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - min_value: { - type: 'number', - nullable: true - }, - max_value: { - type: 'number', - nullable: true - }, - unit: { - type: 'string', - nullable: true - } - } - }, - { - description: 'A qualitative (string) measurement, with array of valid/accepted options', - type: 'object', - additionalProperties: false, - required: ['itis_tsn', 'taxon_measurement_id', 'measurement_name', 'measurement_desc', 'options'], - properties: { - itis_tsn: { - type: 'number', - nullable: true - }, - taxon_measurement_id: { - type: 'string' - }, - measurement_name: { - type: 'string' - }, - measurement_desc: { - type: 'string', - nullable: true - }, - options: { - description: 'Valid options for the measurement.', - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: [ - 'taxon_measurement_id', - 'qualitative_option_id', - 'option_label', - 'option_value', - 'option_desc' - ], - properties: { - taxon_measurement_id: { - type: 'string' - }, - qualitative_option_id: { - type: 'string' - }, - option_label: { - type: 'string', - nullable: true - }, - option_value: { - type: 'number' - }, - option_desc: { - type: 'string', - nullable: true - } - } - } - } - } - } - ] - } - } - } -}; - GET.apiDoc = { description: 'Get all observations for the survey.', tags: ['observation'], @@ -259,6 +141,8 @@ export function getSurveyObservationsGeometry(): RequestHandler { const observationData = await observationService.getSurveyObservationsGeometryWithSupplementaryData(surveyId); + await connection.commit(); + return res.status(200).json(observationData); } catch (error) { defaultLog.error({ label: 'getSurveyObservationsGeometry', message: 'error', error }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts index 369be5171c..4a7cc9e858 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/taxon/index.ts @@ -124,6 +124,8 @@ export function getSurveyObservedSpecies(): RequestHandler { const observedSpecies = await observationService.getObservedSpeciesForSurvey(surveyId); + await connection.commit(); + const species = await platformService.getTaxonomyByTsns(observedSpecies.flatMap((species) => species.itis_tsn)); const formattedResponse = species.map((taxon) => ({ ...taxon, tsn: taxon.tsn })); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.ts index 6ed875b96e..fb33e0a1bb 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/upload.ts @@ -52,6 +52,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey observation submission file to upload', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts index f7e7fe4a16..3718318d87 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/observations/{surveyObservationId}/index.ts @@ -203,6 +203,8 @@ export function getSurveyObservation(): RequestHandler { const observationData = await observationService.getSurveyObservationById(surveyId, surveyObservationId); + await connection.commit(); + return res.status(200).json(observationData); } catch (error) { defaultLog.error({ label: 'getSurveyObservation', message: 'error', error }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.ts index ea5af0daf4..fc5b8597cf 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/participants/index.ts @@ -140,6 +140,7 @@ export function getSurveyParticipants(): RequestHandler { return res.status(200).json({ participants: result }); } catch (error) { defaultLog.error({ label: 'getSurveyParticipants', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); @@ -195,6 +196,7 @@ POST.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { @@ -282,6 +284,7 @@ export function createSurveyParticipants(): RequestHandler { return res.status(200).send(); } catch (error) { defaultLog.error({ label: 'insertProjectParticipants', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/participants/{surveyParticipationId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/participants/{surveyParticipationId}.ts index 9e98e5c334..fdf4a16cad 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/participants/{surveyParticipationId}.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/participants/{surveyParticipationId}.ts @@ -64,6 +64,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts index ff1463609b..8bc11bc7f5 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/delete.ts @@ -59,6 +59,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey sample site delete request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts index 4be0d3eea6..46f77143c1 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.test.ts @@ -16,23 +16,6 @@ describe('getSurveySampleLocationRecord', () => { sinon.restore(); }); - it('should throw a 400 error when no surveyId is provided', async () => { - const dbConnectionObj = getMockDBConnection(); - - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); - - try { - const requestHandler = get_survey_sample_site_record.getSurveySampleLocationRecord(); - await requestHandler(mockReq, mockRes, mockNext); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required param `surveyId`'); - } - }); - it('should catch and re-throw an error if SampleLocationService throws an error', async () => { const dbConnectionObj = getMockDBConnection(); @@ -109,37 +92,10 @@ describe('getSurveySampleLocationRecord', () => { describe('createSurveySampleSiteRecord', () => { const dbConnectionObj = getMockDBConnection(); - const sampleReq = { - keycloak_token: {}, - body: { - participants: [[1, 1, 'job']] - }, - params: { - surveyId: 1 - } - } as any; - afterEach(() => { sinon.restore(); }); - it('should throw a 400 error when no surveyId in the param', async () => { - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - try { - const result = create_survey_sample_site_record.createSurveySampleSiteRecord(); - await result( - { ...sampleReq, params: { ...sampleReq.params, surveyId: null } }, - null as unknown as any, - null as unknown as any - ); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required path param `surveyId`'); - } - }); - it('should work', async () => { sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts index 67dfbfd34c..763ff87dcc 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/index.ts @@ -2,12 +2,12 @@ import { RequestHandler } from 'express'; import { Operation } from 'express-openapi'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; import { getDBConnection } from '../../../../../../database/db'; -import { HTTP400 } from '../../../../../../errors/http-error'; import { GeoJSONFeature } from '../../../../../../openapi/schemas/geoJson'; import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../../../../../openapi/schemas/pagination'; +import { surveyBlockSchema } from '../../../../../../openapi/schemas/survey'; import { techniqueSimpleViewSchema } from '../../../../../../openapi/schemas/technique'; import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; import { PostSampleLocations, SampleLocationService } from '../../../../../../services/sample-location-service'; @@ -44,7 +44,7 @@ export const GET: Operation = [ ]; GET.apiDoc = { - description: 'Get all survey sample sites.', + description: 'Get survey sample sites.', tags: ['survey'], security: [ { @@ -70,6 +70,16 @@ GET.apiDoc = { }, required: true }, + { + in: 'query', + name: 'keyword', + schema: { + type: 'string', + description: + 'A keyword to search for in the sample site name or description. If provided, pagination will be ignored.' + }, + required: false + }, ...paginationRequestQueryParamSchema ], responses: { @@ -86,7 +96,7 @@ GET.apiDoc = { items: { type: 'object', additionalProperties: false, - required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geojson'], + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], properties: { survey_sample_site_id: { type: 'integer', @@ -104,8 +114,9 @@ GET.apiDoc = { type: 'string', maxLength: 250 }, - geojson: { - ...(GeoJSONFeature as object) + geometry_type: { + type: 'string', + maxLength: 50 }, sample_methods: { type: 'array', @@ -257,29 +268,28 @@ GET.apiDoc = { }; /** - * Get all survey sample sites. + * Get all survey sample sites, paginated or filtered by keyword. * * @returns {RequestHandler} */ export function getSurveySampleLocationRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required param `surveyId`'); - } - const connection = getDBConnection(req.keycloak_token); try { - await connection.open(); - const surveyId = Number(req.params.surveyId); + + const keyword = req.query.keyword as string | undefined; + const paginationOptions = makePaginationOptionsFromRequest(req); + await connection.open(); + const sampleLocationService = new SampleLocationService(connection); - const sampleSites = await sampleLocationService.getSampleLocationsForSurveyId( - surveyId, - ensureCompletePaginationOptions(paginationOptions) - ); + const sampleSites = await sampleLocationService.getSampleLocationsForSurveyId(surveyId, { + keyword: keyword, + pagination: ensureCompletePaginationOptions(paginationOptions) + }); const sampleSitesTotalCount = await sampleLocationService.getSampleLocationsCountBySurveyId(surveyId); @@ -347,6 +357,7 @@ POST.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { @@ -427,47 +438,7 @@ POST.apiDoc = { }, blocks: { type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['survey_block_id'], - properties: { - survey_block_id: { - type: 'number' - }, - survey_id: { - type: 'integer' - }, - name: { - type: 'string' - }, - description: { - type: 'string' - }, - sample_block_count: { - type: 'number' - }, - create_date: { - type: 'string', - nullable: true - }, - create_user: { - type: 'integer', - nullable: true - }, - update_date: { - type: 'string', - nullable: true - }, - update_user: { - type: 'integer', - nullable: true - }, - revision_count: { - type: 'number' - } - } - } + items: surveyBlockSchema }, stratums: { type: 'array', @@ -559,10 +530,6 @@ POST.apiDoc = { export function createSurveySampleSiteRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required path param `surveyId`'); - } - const connection = getDBConnection(req.keycloak_token); try { @@ -581,7 +548,7 @@ export function createSurveySampleSiteRecord(): RequestHandler { return res.status(201).send(); } catch (error) { - defaultLog.error({ label: 'insertProjectParticipants', message: 'error', error }); + defaultLog.error({ label: 'createSurveySampleSiteRecord', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts new file mode 100644 index 0000000000..e64f42ca3d --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.test.ts @@ -0,0 +1,72 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../../../../database/db'; +import { HTTPError } from '../../../../../../errors/http-error'; +import { SampleLocationService } from '../../../../../../services/sample-location-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../__mocks__/db'; +import { getSurveySampleSitesGeometry } from './spatial'; + +chai.use(sinonChai); + +describe('getSurveySampleSitesGeometry', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should catch and re-throw an error if SampleLocationService throws an error', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + sinon.stub(SampleLocationService.prototype, 'getSampleLocationsGeometryBySurveyId').rejects(new Error('an error')); + + try { + const requestHandler = getSurveySampleSitesGeometry(); + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('an error'); + } + }); + + it('should return sampleSites on success', async () => { + const dbConnectionObj = getMockDBConnection(); + + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + projectId: '1', + surveyId: '1' + }; + + const sampleSiteData = [ + { + survey_sample_site_id: 1, + geojson: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [0, 0] } } + }, + { + survey_sample_site_id: 2, + geojson: { type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [1, 1] } } + } + ]; + + sinon.stub(SampleLocationService.prototype, 'getSampleLocationsGeometryBySurveyId').resolves(sampleSiteData); + + const requestHandler = getSurveySampleSitesGeometry(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(mockRes.jsonValue).to.eql({ sampleSites: sampleSiteData }); + }); +}); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts new file mode 100644 index 0000000000..cdcd793460 --- /dev/null +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/spatial.ts @@ -0,0 +1,143 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { PROJECT_PERMISSION, SYSTEM_ROLE } from '../../../../../../constants/roles'; +import { getDBConnection } from '../../../../../../database/db'; +import { GeoJSONFeature } from '../../../../../../openapi/schemas/geoJson'; +import { authorizeRequestHandler } from '../../../../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../../../../services/sample-location-service'; +import { getLogger } from '../../../../../../utils/logger'; + +const defaultLog = getLogger('paths/project/{projectId}/survey/{surveyId}/sample-site/spatial'); + +export const GET: Operation = [ + authorizeRequestHandler((req) => { + return { + or: [ + { + validProjectPermissions: [ + PROJECT_PERMISSION.COORDINATOR, + PROJECT_PERMISSION.COLLABORATOR, + PROJECT_PERMISSION.OBSERVER + ], + surveyId: Number(req.params.surveyId), + discriminator: 'ProjectPermission' + }, + { + validSystemRoles: [SYSTEM_ROLE.DATA_ADMINISTRATOR], + discriminator: 'SystemRole' + } + ] + }; + }), + getSurveySampleSitesGeometry() +]; + +GET.apiDoc = { + description: 'Get spatial information for all sample sites in the survey.', + tags: ['survey'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Survey sample sites spatial get response.', + content: { + 'application/json': { + schema: { + type: 'object', + additionalProperties: false, + nullable: true, + required: ['sampleSites'], + properties: { + sampleSites: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_site_id', 'geojson'], + properties: { + survey_sample_site_id: { + type: 'integer' + }, + geojson: { ...(GeoJSONFeature as object) } + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Fetch geometry for all sampling sites in the survey + * + * @export + * @return {*} {RequestHandler} + */ +export function getSurveySampleSitesGeometry(): RequestHandler { + return async (req, res) => { + const surveyId = Number(req.params.surveyId); + + defaultLog.debug({ label: 'getSurveySampleSitesGeometry', surveyId }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const sampleSiteService = new SampleLocationService(connection); + + const sampleSiteData = await sampleSiteService.getSampleLocationsGeometryBySurveyId(surveyId); + + await connection.commit(); + + return res.status(200).json({ sampleSites: sampleSiteData }); + } catch (error) { + defaultLog.error({ label: 'getSurveySampleSitesGeometry', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts index 310e7007a0..5008d9bf7e 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.test.ts @@ -5,7 +5,7 @@ import sinonChai from 'sinon-chai'; import { deleteSurveySampleSiteRecord, getSurveySampleLocationRecord, updateSurveySampleSite } from '.'; import * as db from '../../../../../../../database/db'; import { HTTPError } from '../../../../../../../errors/http-error'; -import { UpdateSampleSiteRecord } from '../../../../../../../repositories/sample-location-repository'; +import { UpdateSampleSiteRecord } from '../../../../../../../repositories/sample-location-repository/sample-location-repository'; import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; import { getMockDBConnection, getRequestHandlerMocks } from '../../../../../../../__mocks__/db'; @@ -212,38 +212,12 @@ describe('deleteSurveySampleSiteRecord', () => { }); describe('getSurveySampleLocationRecord', () => { - const dbConnectionObj = getMockDBConnection(); - - const sampleReq = { - keycloak_token: {}, - params: { - surveyId: 1, - surveySampleSiteId: 1 - } - } as any; - afterEach(() => { sinon.restore(); }); - it('should throw a 400 error when no surveySampleSiteId in the param', async () => { - sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); - - try { - const result = getSurveySampleLocationRecord(); - await result( - { ...sampleReq, params: { ...sampleReq.params, surveySampleSiteId: null } }, - null as unknown as any, - null as unknown as any - ); - expect.fail(); - } catch (actualError) { - expect((actualError as HTTPError).status).to.equal(400); - expect((actualError as HTTPError).message).to.equal('Missing required param `surveySampleSiteId`'); - } - }); - it('should successfully get a sample location record', async () => { + const dbConnectionObj = getMockDBConnection(); sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); const getSurveySampleLocationBySiteIdStub = sinon @@ -264,4 +238,35 @@ describe('getSurveySampleLocationRecord', () => { expect(mockRes.status).to.have.been.calledWith(200); expect(getSurveySampleLocationBySiteIdStub).to.have.been.calledOnce; }); + + it('catches and re-throws error', async () => { + const dbConnectionObj = getMockDBConnection({ + rollback: sinon.stub(), + release: sinon.stub() + }); + sinon.stub(db, 'getDBConnection').returns(dbConnectionObj); + + const mockError = new Error('a test error'); + + sinon.stub(SampleLocationService.prototype, 'getSurveySampleLocationBySiteId').rejects(mockError); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + + mockReq.params = { + surveyId: '1', + surveySampleSiteId: '2' + }; + + const requestHandler = getSurveySampleLocationRecord(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail(); + } catch (actualError) { + expect((actualError as HTTPError).message).to.equal('a test error'); + + expect(dbConnectionObj.rollback).to.have.been.calledOnce; + expect(dbConnectionObj.release).to.have.been.calledOnce; + } + }); }); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts index 92d1636b01..91be150913 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/index.ts @@ -5,7 +5,7 @@ import { getDBConnection } from '../../../../../../../database/db'; import { HTTP400, HTTP409 } from '../../../../../../../errors/http-error'; import { GeoJSONFeature } from '../../../../../../../openapi/schemas/geoJson'; import { techniqueSimpleViewSchema } from '../../../../../../../openapi/schemas/technique'; -import { UpdateSampleLocationRecord } from '../../../../../../../repositories/sample-location-repository'; +import { UpdateSampleLocationRecord } from '../../../../../../../repositories/sample-location-repository/sample-location-repository'; import { authorizeRequestHandler } from '../../../../../../../request-handlers/security/authorization'; import { ObservationService } from '../../../../../../../services/observation-service'; import { SampleLocationService } from '../../../../../../../services/sample-location-service'; @@ -70,6 +70,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { @@ -426,6 +427,15 @@ GET.apiDoc = { minimum: 1 }, required: true + }, + { + in: 'path', + name: 'surveySampleSiteId', + schema: { + type: 'integer', + minimum: 1 + }, + required: true } ], responses: { @@ -638,13 +648,6 @@ GET.apiDoc = { */ export function getSurveySampleLocationRecord(): RequestHandler { return async (req, res) => { - if (!req.params.surveyId) { - throw new HTTP400('Missing required param `surveyId`'); - } - if (!req.params.surveySampleSiteId) { - throw new HTTP400('Missing required param `surveySampleSiteId`'); - } - const connection = getDBConnection(req.keycloak_token); try { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts index fb6a28625b..d91eab33ee 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/index.ts @@ -257,6 +257,7 @@ POST.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts index 0f5895a10d..8fc68d24b2 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/index.ts @@ -69,6 +69,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts index 1cc42d05ca..100a447ef8 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/index.ts @@ -253,6 +253,7 @@ POST.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts index 47eb99df29..94f164f855 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/sample-site/{surveySampleSiteId}/sample-method/{surveySampleMethodId}/sample-period/{surveySamplePeriodId}.ts @@ -87,6 +87,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts index f1c62cd0ef..d42dbb095b 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/delete.ts @@ -59,6 +59,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey technique delete request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts index c02f2a1b17..4caa4f5864 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.test.ts @@ -253,7 +253,6 @@ describe('getTechniques', () => { expect(mockRes.jsonValue).to.eql({ techniques: [techniqueRecord], - count: 1, pagination: { total: 1, per_page: 1, diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts index 7f08d1ba13..2862f31236 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/index.ts @@ -66,6 +66,7 @@ POST.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { @@ -192,18 +193,14 @@ GET.apiDoc = { 'application/json': { schema: { type: 'object', - required: ['techniques', 'count'], + required: ['techniques', 'pagination'], additionalProperties: false, properties: { techniques: { type: 'array', items: techniqueViewSchema }, - count: { - type: 'number', - description: 'Count of method techniques in the respective survey.' - }, - pagination: { ...paginationResponseSchema } + pagination: paginationResponseSchema } } } @@ -253,7 +250,6 @@ export function getTechniques(): RequestHandler { return res.status(200).json({ techniques, - count: techniquesCount, pagination: makePaginationResponse(techniquesCount, paginationOptions) }); } catch (error) { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts index 72454c0b40..209ac8613f 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/technique/{techniqueId}/index.ts @@ -193,6 +193,7 @@ PUT.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/telemetry/upload.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/telemetry/upload.ts index 71dc705741..ed7d41ac4a 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/telemetry/upload.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/telemetry/upload.ts @@ -52,6 +52,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey telemetry submission file to upload', + required: true, content: { 'multipart/form-data': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts index 9e3ce263c3..7967b36301 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update.ts @@ -71,6 +71,7 @@ PUT.apiDoc = { ], requestBody: { description: 'Survey put request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts index a1ab70d232..6b04f81145 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/update/get.ts @@ -215,6 +215,7 @@ export function getSurveyForUpdate(): RequestHandler { return res.status(200).json({ surveyData: surveyData }); } catch (error) { defaultLog.error({ label: 'getSurveyForView', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts index e9c43481c7..07efd3e67c 100644 --- a/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts +++ b/api/src/paths/project/{projectId}/survey/{surveyId}/view.ts @@ -162,16 +162,18 @@ export function getSurvey(): RequestHandler { const surveyService = new SurveyService(connection); - const surveyData = await surveyService.getSurveyById(surveyId); - // @TODO safe to delete survey supplementary data code? - const surveySupplementaryData = await surveyService.getSurveySupplementaryDataById(Number(req.params.surveyId)); + const [surveyData, surveySupplementaryData] = await Promise.all([ + surveyService.getSurveyById(surveyId), + surveyService.getSurveySupplementaryDataById(surveyId) + ]); await connection.commit(); return res.status(200).json({ surveyData: surveyData, surveySupplementaryData: surveySupplementaryData }); } catch (error) { defaultLog.error({ label: 'getSurveyForView', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/project/{projectId}/update.ts b/api/src/paths/project/{projectId}/update.ts index 8e87f68f16..415be708f9 100644 --- a/api/src/paths/project/{projectId}/update.ts +++ b/api/src/paths/project/{projectId}/update.ts @@ -206,6 +206,7 @@ export function getProjectForUpdate(): RequestHandler { return res.status(200).send(results); } catch (error) { defaultLog.error({ label: 'getProjectForUpdate', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); @@ -242,6 +243,7 @@ PUT.apiDoc = { ], requestBody: { description: 'Project put request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/project/{projectId}/view.ts b/api/src/paths/project/{projectId}/view.ts index 73a6e22987..c33a86928a 100644 --- a/api/src/paths/project/{projectId}/view.ts +++ b/api/src/paths/project/{projectId}/view.ts @@ -188,6 +188,7 @@ export function viewProject(): RequestHandler { return res.status(200).json({ projectData }); } catch (error) { defaultLog.error({ label: 'getProjectForView', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/publish/attachment/resubmit.ts b/api/src/paths/publish/attachment/resubmit.ts index aea21f730f..a24d770e3c 100644 --- a/api/src/paths/publish/attachment/resubmit.ts +++ b/api/src/paths/publish/attachment/resubmit.ts @@ -33,6 +33,7 @@ POST.apiDoc = { ], requestBody: { description: 'attachment submission file to delete or resubmit', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/publish/survey.ts b/api/src/paths/publish/survey.ts index 4059c98833..bec010d001 100644 --- a/api/src/paths/publish/survey.ts +++ b/api/src/paths/publish/survey.ts @@ -37,6 +37,7 @@ POST.apiDoc = { ], requestBody: { description: 'Survey observation submission file to upload', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/reference/get/technique-attribute.ts b/api/src/paths/reference/get/technique-attribute.ts index 67119bf603..7a59eee333 100644 --- a/api/src/paths/reference/get/technique-attribute.ts +++ b/api/src/paths/reference/get/technique-attribute.ts @@ -4,7 +4,7 @@ import { getAPIUserDBConnection } from '../../../database/db'; import { TechniqueAttributeService } from '../../../services/technique-attributes-service'; import { getLogger } from '../../../utils/logger'; -const defaultLog = getLogger('paths/reference'); +const defaultLog = getLogger('paths/reference/get/technique-attribute'); export const GET: Operation = [getTechniqueAttributes()]; diff --git a/api/src/paths/reference/get/vantage-mode.test.ts b/api/src/paths/reference/get/vantage-mode.test.ts new file mode 100644 index 0000000000..69e73adb96 --- /dev/null +++ b/api/src/paths/reference/get/vantage-mode.test.ts @@ -0,0 +1,81 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import * as db from '../../../database/db'; +import { HTTPError } from '../../../errors/http-error'; +import { VantageModeService } from '../../../services/vantage-mode-service'; +import { getMockDBConnection, getRequestHandlerMocks } from '../../../__mocks__/db'; +import { getVantageModes } from './vantage-mode'; + +chai.use(sinonChai); + +describe('getVantageModes', () => { + afterEach(() => { + sinon.restore(); + }); + + it('should return vantage modes for method lookup ids', async () => { + const mockVantageModeResponse = [ + { vantage_mode_id: 1, vantage_id: 101, name: 'Mode A', description: 'Description for Mode A' }, + { vantage_mode_id: 2, vantage_id: 102, name: 'Mode B', description: 'Description for Mode B' } + ]; + + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub() + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const getVantageModesByMethodLookupIdsStub = sinon + .stub(VantageModeService.prototype, 'getVantageModesByMethodLookupIds') + .resolves(mockVantageModeResponse); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { methodLookupId: ['1', '2'] }; + + const requestHandler = getVantageModes(); + + await requestHandler(mockReq, mockRes, mockNext); + + expect(getVantageModesByMethodLookupIdsStub).to.have.been.calledOnceWith([1, 2]); + expect(mockRes.jsonValue).to.eql(mockVantageModeResponse); + + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.commit).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + }); + + it('should catch and handle errors', async () => { + const mockDBConnection = getMockDBConnection({ + open: sinon.stub(), + commit: sinon.stub(), + release: sinon.stub(), + rollback: sinon.stub() + }); + + sinon.stub(db, 'getDBConnection').returns(mockDBConnection); + + const getVantageModesByMethodLookupIdsStub = sinon + .stub(VantageModeService.prototype, 'getVantageModesByMethodLookupIds') + .rejects(new Error('Test database error')); + + const { mockReq, mockRes, mockNext } = getRequestHandlerMocks(); + mockReq.query = { methodLookupId: ['1', '2'] }; + + const requestHandler = getVantageModes(); + + try { + await requestHandler(mockReq, mockRes, mockNext); + expect.fail('Expected method to throw'); + } catch (error) { + expect(mockDBConnection.open).to.have.been.calledOnce; + expect(mockDBConnection.rollback).to.have.been.calledOnce; + expect(mockDBConnection.release).to.have.been.calledOnce; + expect(getVantageModesByMethodLookupIdsStub).to.have.been.calledOnceWith([1, 2]); + expect((error as HTTPError).message).to.equal('Test database error'); + } + }); +}); diff --git a/api/src/paths/reference/get/vantage-mode.ts b/api/src/paths/reference/get/vantage-mode.ts new file mode 100644 index 0000000000..1214e239e6 --- /dev/null +++ b/api/src/paths/reference/get/vantage-mode.ts @@ -0,0 +1,85 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection } from '../../../database/db'; +import { vantageModeSchema } from '../../../openapi/schemas/technique'; +import { VantageModeService } from '../../../services/vantage-mode-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/reference/get/vantage-mode'); + +export const GET: Operation = [getVantageModes()]; + +GET.apiDoc = { + description: 'Find vantage modes applicable to method lookup options', + tags: ['reference'], + parameters: [ + { + in: 'query', + name: 'methodLookupId', + schema: { + type: 'array', + items: { + type: 'string' + }, + minItems: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Vantages for a method lookup id.', + content: { + 'application/json': { + schema: vantageModeSchema + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get all vantage modes possible for multiple method lookup ids. + * + * @returns {RequestHandler} + */ +export function getVantageModes(): RequestHandler { + return async (req, res) => { + const connection = getAPIUserDBConnection(); + + try { + const methodLookupIds = (req.query.methodLookupId as string[]).map(Number); + + await connection.open(); + + const vantageModeService = new VantageModeService(connection); + + const response = await vantageModeService.getVantageModesByMethodLookupIds(methodLookupIds); + + await connection.commit(); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getVantageModes', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/paths/sampling-locations/methods/index.ts b/api/src/paths/sampling-locations/methods/index.ts new file mode 100644 index 0000000000..7d1c355545 --- /dev/null +++ b/api/src/paths/sampling-locations/methods/index.ts @@ -0,0 +1,297 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { IMethodAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { ensureCompletePaginationOptions, makePaginationOptionsFromRequest } from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/method/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findMethods() +]; + +GET.apiDoc = { + description: "Gets a list of methods based on the user's permissions and filter criteria.", + tags: ['methods'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsns', + description: 'ITIS TSN numbers', + required: false, + schema: { + type: 'array', + items: { + type: 'integer' + }, + nullable: true + } + }, + { + in: 'query', + name: 'itis_tsn', + description: 'ITIS TSN number', + required: false, + schema: { + type: 'integer', + nullable: true + } + }, + { + in: 'query', + name: 'start_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_date', + description: 'ISO 8601 date string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'start_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'end_time', + description: 'ISO 8601 time string', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'min_count', + description: 'Minimum method count (inclusive).', + required: false, + schema: { + type: 'number', + minimum: 0, + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'methods response object.', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + methods: { + type: 'array', + items: { + type: 'object', + required: [ + 'survey_sample_method_id', + 'survey_sample_site_id', + 'description', + 'method_response_metric_id', + 'technique' + ], + additionalProperties: false, + properties: { + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + description: { + type: 'string', + nullable: true + }, + method_response_metric_id: { + type: 'integer', + minimum: 1 + }, + technique: { + type: 'object', + required: ['method_technique_id', 'name', 'description', 'attractants'], + additionalProperties: false, + properties: { + method_technique_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + attractants: { + type: 'array', + required: ['attractant_lookup_id'], + additionalProperties: false, + items: { + type: 'object', + properties: { + attractant_lookup_id: { + type: 'integer', + minimum: 1 + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get methods for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findMethods(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findMethods' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const methods = await sampleLocationService.findMethods( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ); + + await connection.commit(); + + const response = { + methods: methods + // TODO NICK add count and pagination to response and openapi schema? + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getMethods', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IMethodAdvancedFilters} + */ +function parseQueryParams(req: Request): IMethodAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + sample_site_id: (req.query.sample_site_id && Number(req.query.sample_site_id)) ?? undefined, + keyword: req.query.keyword ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/sampling-locations/periods/index.ts b/api/src/paths/sampling-locations/periods/index.ts new file mode 100644 index 0000000000..e0553c2a2e --- /dev/null +++ b/api/src/paths/sampling-locations/periods/index.ts @@ -0,0 +1,271 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { IPeriodAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/period/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findPeriods() +]; + +GET.apiDoc = { + description: "Gets a list of periods based on the user's permissions and filter criteria.", + tags: ['periods'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'survey_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'sample_site_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'sample_method_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'periods response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['periods', 'pagination'], + additionalProperties: false, + properties: { + periods: { + type: 'array', + items: { + type: 'object', + required: [ + 'survey_sample_period_id', + 'survey_sample_method_id', + 'start_date', + 'start_time', + 'end_date', + 'end_time', + 'sample_method', + 'method_technique', + 'sample_site' + ], + additionalProperties: false, + properties: { + survey_sample_period_id: { + type: 'integer', + minimum: 1 + }, + survey_sample_method_id: { + type: 'integer', + minimum: 1 + }, + start_date: { + type: 'string' + }, + start_time: { + type: 'string', + nullable: true + }, + end_date: { + type: 'string' + }, + end_time: { + type: 'string', + nullable: true + }, + sample_method: { + type: 'object', + required: ['method_response_metric_id'], + additionalProperties: false, + properties: { + method_response_metric_id: { + type: 'integer', + minimum: 1 + } + } + }, + method_technique: { + type: 'object', + required: ['method_technique_id', 'name'], + additionalProperties: false, + properties: { + method_technique_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + } + } + }, + sample_site: { + type: 'object', + required: ['survey_sample_site_id', 'name'], + additionalProperties: false, + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + name: { + type: 'string' + } + } + } + } + } + }, + pagination: paginationResponseSchema + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get periods for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findPeriods(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findPeriods' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const [periods, periodsCount] = await Promise.all([ + sampleLocationService.findPeriods( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + sampleLocationService.findPeriodsCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + periods: periods, + pagination: makePaginationResponse(periodsCount, paginationOptions) + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'getPeriods', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {IPeriodAdvancedFilters} + */ +function parseQueryParams(req: Request): IPeriodAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + sample_site_id: (req.query.sample_site_id && Number(req.query.sample_site_id)) ?? undefined, + sample_method_id: (req.query.sample_method_id && Number(req.query.sample_method_id)) ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/sampling-locations/sites/index.ts b/api/src/paths/sampling-locations/sites/index.ts new file mode 100644 index 0000000000..14db25a686 --- /dev/null +++ b/api/src/paths/sampling-locations/sites/index.ts @@ -0,0 +1,255 @@ +import { Request, RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { ISiteAdvancedFilters } from '../../../models/sampling-locations-view'; +import { paginationRequestQueryParamSchema, paginationResponseSchema } from '../../../openapi/schemas/pagination'; +import { authorizeRequestHandler, userHasValidRole } from '../../../request-handlers/security/authorization'; +import { SampleLocationService } from '../../../services/sample-location-service'; +import { getLogger } from '../../../utils/logger'; +import { + ensureCompletePaginationOptions, + makePaginationOptionsFromRequest, + makePaginationResponse +} from '../../../utils/pagination'; +import { getSystemUserFromRequest } from '../../../utils/request'; + +const defaultLog = getLogger('paths/site/index'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + discriminator: 'SystemUser' + } + ] + }; + }), + findSites() +]; + +GET.apiDoc = { + description: "Gets a list of sites based on the user's permissions and filter criteria.", + tags: ['sites'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'query', + name: 'survey_id', + required: false, + schema: { + type: 'integer', + minimum: 1, + nullable: true + } + }, + { + in: 'query', + name: 'keyword', + required: false, + schema: { + type: 'string', + nullable: true + } + }, + { + in: 'query', + name: 'system_user_id', + required: false, + schema: { + type: 'number', + minimum: 1, + nullable: true + } + }, + ...paginationRequestQueryParamSchema + ], + responses: { + 200: { + description: 'Sites response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['sites'], + additionalProperties: false, + properties: { + sites: { + type: 'array', + items: { + type: 'object', + required: ['survey_sample_site_id', 'survey_id', 'name', 'description', 'geometry_type'], + additionalProperties: false, + properties: { + survey_sample_site_id: { + type: 'integer', + minimum: 1 + }, + survey_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string', + nullable: true + }, + geometry_type: { + type: 'string' + }, + blocks: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_block_id', 'survey_sample_site_id', 'survey_block_id'], + properties: { + survey_sample_block_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_block_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + }, + stratums: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['survey_sample_stratum_id', 'survey_sample_site_id', 'survey_stratum_id'], + properties: { + survey_sample_stratum_id: { + type: 'number' + }, + survey_sample_site_id: { + type: 'number' + }, + survey_stratum_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + } + } + } + } + } + } + }, + pagination: paginationResponseSchema + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +/** + * Get sites for the current user, based on their permissions and filter criteria. + * + * @returns {RequestHandler} + */ +export function findSites(): RequestHandler { + return async (req, res) => { + defaultLog.debug({ label: 'findSites' }); + + const connection = getDBConnection(req.keycloak_token); + + try { + await connection.open(); + + const systemUserId = connection.systemUserId(); + + const systemUser = getSystemUserFromRequest(req); + + const isUserAdmin = userHasValidRole( + [SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR], + systemUser.role_names + ); + + const filterFields = parseQueryParams(req); + + const paginationOptions = makePaginationOptionsFromRequest(req); + + const sampleLocationService = new SampleLocationService(connection); + + const [sites, sitesCount] = await Promise.all([ + sampleLocationService.findSites( + isUserAdmin, + systemUserId, + filterFields, + ensureCompletePaginationOptions(paginationOptions) + ), + sampleLocationService.findSitesCount(isUserAdmin, systemUserId, filterFields) + ]); + + await connection.commit(); + + const response = { + sites: sites, + pagination: makePaginationResponse(sitesCount, paginationOptions) + }; + + // Allow browsers to cache this response for 30 seconds + res.setHeader('Cache-Control', 'private, max-age=30'); + + return res.status(200).json(response); + } catch (error) { + defaultLog.error({ label: 'findSites', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} + +/** + * Parse the query parameters from the request into the expected format. + * + * @param {Request} req + * @return {*} {ISiteAdvancedFilters} + */ +function parseQueryParams(req: Request): ISiteAdvancedFilters { + return { + survey_id: (req.query.survey_id && Number(req.query.survey_id)) ?? undefined, + keyword: req.query.keyword ?? undefined, + system_user_id: req.query.system_user_id ?? undefined + }; +} diff --git a/api/src/paths/spatial/regions.ts b/api/src/paths/spatial/regions.ts index 42e113cd20..ba943abbfc 100644 --- a/api/src/paths/spatial/regions.ts +++ b/api/src/paths/spatial/regions.ts @@ -31,6 +31,7 @@ POST.apiDoc = { } ], requestBody: { + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/telemetry/device/index.ts b/api/src/paths/telemetry/device/index.ts index 4d26d80342..a0f4006f9f 100644 --- a/api/src/paths/telemetry/device/index.ts +++ b/api/src/paths/telemetry/device/index.ts @@ -31,6 +31,7 @@ POST.apiDoc = { ], requestBody: { description: 'Device body', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/telemetry/manual/process.ts b/api/src/paths/telemetry/manual/process.ts index fcf289e604..6a4f22b287 100644 --- a/api/src/paths/telemetry/manual/process.ts +++ b/api/src/paths/telemetry/manual/process.ts @@ -38,6 +38,7 @@ POST.apiDoc = { ], requestBody: { description: 'Request body', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/user/add.ts b/api/src/paths/user/add.ts index add68d6994..2c60f4457f 100644 --- a/api/src/paths/user/add.ts +++ b/api/src/paths/user/add.ts @@ -40,6 +40,7 @@ POST.apiDoc = { ], requestBody: { description: 'Add system user request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/paths/user/list.ts b/api/src/paths/user/list.ts index a209757717..36769518c6 100644 --- a/api/src/paths/user/list.ts +++ b/api/src/paths/user/list.ts @@ -84,6 +84,7 @@ export function getUserList(): RequestHandler { return res.status(200).json(response); } catch (error) { defaultLog.error({ label: 'getUserList', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/user/{userId}/projects/get.ts b/api/src/paths/user/{userId}/projects/get.ts index 6908d366e2..fd3e922fa7 100644 --- a/api/src/paths/user/{userId}/projects/get.ts +++ b/api/src/paths/user/{userId}/projects/get.ts @@ -149,6 +149,7 @@ export function getAllUserProjects(): RequestHandler { return res.status(200).json(getUserProjectsListResponse); } catch (error) { defaultLog.error({ label: 'getAllUserProjects', message: 'error', error }); + await connection.rollback(); throw error; } finally { connection.release(); diff --git a/api/src/paths/user/{userId}/system-roles/update.ts b/api/src/paths/user/{userId}/system-roles/update.ts index eba23a85bc..e6bc4aa29f 100644 --- a/api/src/paths/user/{userId}/system-roles/update.ts +++ b/api/src/paths/user/{userId}/system-roles/update.ts @@ -43,6 +43,7 @@ PATCH.apiDoc = { ], requestBody: { description: 'Update system role for a user request object.', + required: true, content: { 'application/json': { schema: { diff --git a/api/src/repositories/alert-repository.test.ts b/api/src/repositories/alert-repository.test.ts new file mode 100644 index 0000000000..0f6c05950e --- /dev/null +++ b/api/src/repositories/alert-repository.test.ts @@ -0,0 +1,169 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { IAlertSeverity } from '../models/alert-view'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AlertRepository } from './alert-repository'; + +chai.use(sinonChai); + +describe('AlertRepository', () => { + it('should construct', () => { + const mockDBConnection = getMockDBConnection(); + const alertRepository = new AlertRepository(mockDBConnection); + + expect(alertRepository).to.be.instanceof(AlertRepository); + }); + + describe('getAlerts', () => { + it('should return an array of alerts with empty filters', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlerts({}); + + expect(response).to.be.an('array').that.is.not.empty; + expect(response[0]).to.have.property('alert_id', 1); + }); + + it('should apply filters when provided', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error', + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlerts({ expiresBefore: '2024-01-01', types: ['type1'] }); + + expect(response).to.equal(mockRows); + }); + }); + + describe('getAlertById', () => { + it('should return a specific alert by its Id', async () => { + const mockRows = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'This is an alert.', + alert_type_id: 1, + data: {}, + severity: 'error', + record_end_date: null, + status: 'active' + } + ]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + knex: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.getAlertById(1); + + expect(response).to.have.property('alert_id', 1); + }); + }); + + describe('updateAlert', () => { + it('should update an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + const alert = { + alert_id: 1, + name: 'Updated Alert', + message: 'Updated message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + const response = await alertRepository.updateAlert(alert); + + expect(response).to.equal(1); + }); + }); + + describe('createAlert', () => { + it('should create an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + const alert = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + const response = await alertRepository.createAlert(alert); + + expect(response).to.equal(1); + }); + }); + + describe('deleteAlert', () => { + it('should delete an alert and return its Id', async () => { + const mockRows = [{ alert_id: 1 }]; + const mockQueryResponse = { rows: mockRows, rowCount: 1 } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const alertRepository = new AlertRepository(mockDBConnection); + + const response = await alertRepository.deleteAlert(1); + + expect(response).to.equal(1); + }); + }); +}); diff --git a/api/src/repositories/alert-repository.ts b/api/src/repositories/alert-repository.ts new file mode 100644 index 0000000000..46ca743e86 --- /dev/null +++ b/api/src/repositories/alert-repository.ts @@ -0,0 +1,189 @@ +import { Knex } from 'knex'; +import SQL from 'sql-template-strings'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { BaseRepository } from './base-repository'; + +/** + * A repository class for accessing alert data. + * + * @export + * @class AlertRepository + * @extends {BaseRepository} + */ +export class AlertRepository extends BaseRepository { + /** + * Builds query for all alert records without filtering any records, and adds a status field based on record_end_date + * + * @return {*} {Knex.QueryBuilder} + * @memberof AlertRepository + */ + _getAlertBaseQuery(): Knex.QueryBuilder { + const knex = getKnex(); + + return knex + .select( + 'alert.alert_id', + 'alert.name', + 'alert.message', + 'alert.alert_type_id', + 'alert.data', + 'alert.severity', + 'alert.record_end_date', + knex.raw(` + CASE + WHEN alert.record_end_date < NOW() THEN 'expired' + ELSE 'active' + END AS status + `) + ) + .from('alert') + .orderBy('alert.create_date', 'DESC'); + } + + /** + * Get alert records with optional filters applied + * + * @param {IAlertFilterObject} filterObject + * @return {*} {Promise} + * @memberof AlertRepository + */ + async getAlerts(filterObject: IAlertFilterObject): Promise { + const queryBuilder = this._getAlertBaseQuery(); + + if (filterObject.expiresAfter) { + queryBuilder.where((qb) => { + qb.whereRaw(`alert.record_end_date >= ?`, [filterObject.expiresAfter]).orWhereNull('alert.record_end_date'); + }); + } + + if (filterObject.expiresBefore) { + queryBuilder.where((qb) => { + qb.whereRaw(`alert.record_end_date < ?`, [filterObject.expiresBefore]); + }); + } + + if (filterObject.types && filterObject.types.length > 0) { + queryBuilder + .join('alert_type as at', 'at.alert_type_id', 'alert.alert_type_id') + .whereRaw('lower(at.name) = ANY(?)', [filterObject.types.map((type) => type.toLowerCase())]); + } + + const response = await this.connection.knex(queryBuilder, IAlert); + + return response.rows; + } + + /** + * Get a specific alert by its Id + * + * @param {number} alertId + * @return {*} {Promise} + * @memberof AlertRepository + */ + async getAlertById(alertId: number): Promise { + const queryBuilder = this._getAlertBaseQuery(); + + queryBuilder.where('alert_id', alertId); + + const response = await this.connection.knex(queryBuilder, IAlert); + + return response.rows[0]; + } + + /** + * Update system alert. + * + * @param {IAlertUpdateObject} alert + * @return {*} Promise + * @memberof AlertRepository + */ + async updateAlert(alert: IAlertUpdateObject): Promise { + const sqlStatement = SQL` + UPDATE alert + SET + name = ${alert.name}, + message = ${alert.message}, + alert_type_id = ${alert.alert_type_id}, + severity = ${alert.severity}, + data = ${JSON.stringify(alert.data)}::json, + record_end_date = ${alert.record_end_date} + WHERE + alert_id = ${alert.alert_id} + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to update alert', [ + 'AlertRepository->updateAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } + + /** + * Create system alert. + * + * @param {IAlertCreateObject} alert + * @return {*} Promise + * @memberof AlertRepository + */ + async createAlert(alert: IAlertCreateObject): Promise { + const sqlStatement = SQL` + INSERT INTO + alert (name, message, alert_type_id, data, severity, record_end_date) + VALUES + (${alert.name}, ${alert.message}, ${alert.alert_type_id}, ${JSON.stringify(alert.data)}, ${alert.severity}, ${ + alert.record_end_date + }) + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to create alert', [ + 'AlertRepository->createAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } + + /** + * Delete system alert. + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertRepository + */ + async deleteAlert(alertId: number): Promise { + const sqlStatement = SQL` + DELETE FROM + alert + WHERE + alert_id = ${alertId} + RETURNING alert_id + ; + `; + + const response = await this.connection.sql(sqlStatement); + + if (response.rowCount !== 1) { + throw new ApiExecuteSQLError('Failed to delete alert', [ + 'AlertRepository->deleteAlert', + 'rowCount was !== 1, expected rowCount === 1' + ]); + } + + return response.rows[0].alert_id; + } +} diff --git a/api/src/repositories/code-repository.ts b/api/src/repositories/code-repository.ts index 07c079cda4..53e9df3c85 100644 --- a/api/src/repositories/code-repository.ts +++ b/api/src/repositories/code-repository.ts @@ -25,6 +25,8 @@ const SurveyProgressCode = ICode.extend({ description: z.string() }); const MethodResponseMetricsCode = ICode.extend({ description: z.string() }); const AttractantCode = ICode.extend({ description: z.string() }); const ObservationSubcountSignCode = ICode.extend({ description: z.string() }); +const AlertTypeCode = ICode.extend({ description: z.string() }); +const VantageCode = ICode.extend({ description: z.string() }); export const IAllCodeSets = z.object({ management_action_type: CodeSet(), @@ -46,7 +48,9 @@ export const IAllCodeSets = z.object({ survey_progress: CodeSet(SurveyProgressCode.shape), method_response_metrics: CodeSet(MethodResponseMetricsCode.shape), attractants: CodeSet(AttractantCode.shape), - observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape) + observation_subcount_signs: CodeSet(ObservationSubcountSignCode.shape), + alert_types: CodeSet(AlertTypeCode.shape), + vantages: CodeSet(VantageCode.shape) }); export type IAllCodeSets = z.infer; @@ -466,4 +470,47 @@ export class CodeRepository extends BaseRepository { return response.rows; } + + /** + * Fetch alert type codes + * + * @return {*} + * @memberof CodeRepository + */ + async getAlertTypes() { + const sqlStatement = SQL` + SELECT + alert_type_id AS id, + name, + description + FROM alert_type + WHERE record_end_date IS null + ORDER BY name ASC; + `; + + const response = await this.connection.sql(sqlStatement, AlertTypeCode); + + return response.rows; + } + + /** + * Fetch vantages associated with vantage modes + * + * @return {*} + * @memberof CodeRepository + */ + async getVantages() { + const sqlStatement = SQL` + SELECT + vantage_id AS id, + name, + description + FROM vantage + WHERE record_end_date IS null; + `; + + const response = await this.connection.sql(sqlStatement, VantageCode); + + return response.rows; + } } diff --git a/api/src/repositories/critter-attachment-repository.interface.ts b/api/src/repositories/critter-attachment-repository.interface.ts new file mode 100644 index 0000000000..098df91695 --- /dev/null +++ b/api/src/repositories/critter-attachment-repository.interface.ts @@ -0,0 +1,15 @@ +export type CritterCaptureAttachmentPayload = { + critter_id: number; + critterbase_capture_id: string; + file_name: string; + file_size: number; + key: string; +}; + +export type CritterMortalityAttachmentPayload = { + critter_id: number; + critterbase_mortality_id: string; + file_name: string; + file_size: number; + key: string; +}; diff --git a/api/src/repositories/critter-attachment-repository.test.ts b/api/src/repositories/critter-attachment-repository.test.ts new file mode 100644 index 0000000000..1dc3e7e0c3 --- /dev/null +++ b/api/src/repositories/critter-attachment-repository.test.ts @@ -0,0 +1,136 @@ +import chai, { expect } from 'chai'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { CritterAttachmentRepository } from './critter-attachment-repository'; + +chai.use(sinonChai); + +describe('CritterAttachmentRepository', () => { + describe('getCritterCaptureAttachmentS3Key', () => { + it('gets S3 key', async () => { + const mockResponse = { rows: [{ key: 'key' }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.getCritterCaptureAttachmentS3Key(1, 1); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.be.equal('key'); + }); + + it('throws error when no rows are returned', async () => { + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + try { + await service.getCritterCaptureAttachmentS3Key(1, 1); + } catch (error: any) { + expect(error.message).to.be.equal('Failed to get critter capture attachment signed URL'); + } + }); + }); + + describe('upsertCritterCaptureAttachment', () => { + it('upserts attachment', async () => { + const mockResponse = { rows: [{ critter_capture_attachment_id: 1, key: 'key' }], rowCount: 1 } as any as Promise< + QueryResult + >; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.upsertCritterCaptureAttachment({} as any); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.be.deep.equal({ critter_capture_attachment_id: 1, key: 'key' }); + }); + + it('throws error when no rows are returned', async () => { + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + try { + await service.upsertCritterCaptureAttachment({} as any); + } catch (error: any) { + expect(error.message).to.be.equal('Failed to upsert critter capture attachment data'); + } + }); + }); + + describe('upsertCritterMortalityAttachment', () => { + it('upserts attachment', async () => { + const mockResponse = { + rows: [{ critter_mortality_attachment_id: 1, key: 'key' }], + rowCount: 1 + } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.upsertCritterMortalityAttachment({} as any); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.be.deep.equal({ critter_mortality_attachment_id: 1, key: 'key' }); + }); + + it('throws error when no rows are returned', async () => { + const mockResponse = { rows: [], rowCount: 0 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + try { + await service.upsertCritterMortalityAttachment({} as any); + } catch (error: any) { + expect(error.message).to.be.equal('Failed to upsert critter mortality attachment data'); + } + }); + }); + + describe('findAllCritterCaptureAttachments', () => { + it('finds all attachments', async () => { + const mockResponse = { rows: [{ key: 'key' }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + const result = await service.findAllCritterCaptureAttachments(1, 'uuid'); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.deep.equal([{ key: 'key' }]); + }); + }); + + describe('findCaptureAttachmentsByCritterId', () => { + it('finds all attachments by critter ID', async () => { + const mockResponse = { rows: [{ key: 'key' }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + const result = await service.findCaptureAttachmentsByCritterId(1); + + expect(mockConnection.sql).to.have.been.calledOnce; + expect(result).to.deep.equal([{ key: 'key' }]); + }); + }); + + describe('deleteCritterCaptureAttachments', () => { + it('deletes attachment', async () => { + const mockResponse = { rows: [{ key: 1 }, { key: 2 }], rowCount: 1 } as any as Promise>; + const mockConnection = getMockDBConnection({ knex: sinon.stub().resolves(mockResponse) }); + + const service = new CritterAttachmentRepository(mockConnection); + + const result = await service.deleteCritterCaptureAttachments(1, [1, 2]); + + expect(mockConnection.knex).to.have.been.calledOnce; + expect(result).to.deep.equal([1, 2]); + }); + }); +}); diff --git a/api/src/repositories/critter-attachment-repository.ts b/api/src/repositories/critter-attachment-repository.ts new file mode 100644 index 0000000000..a977a44bc5 --- /dev/null +++ b/api/src/repositories/critter-attachment-repository.ts @@ -0,0 +1,246 @@ +import SQL from 'sql-template-strings'; +import { z } from 'zod'; +import { ATTACHMENT_TYPE } from '../constants/attachments'; +import { + CritterCaptureAttachmentModel, + CritterCaptureAttachmentRecord +} from '../database-models/critter_capture_attachment'; +import { getKnex } from '../database/db'; +import { ApiExecuteSQLError } from '../errors/api-error'; +import { BaseRepository } from './base-repository'; +import { + CritterCaptureAttachmentPayload, + CritterMortalityAttachmentPayload +} from './critter-attachment-repository.interface'; + +/** + * A repository class for accessing Critter attachment data. + * + * @export + * @class CritterAttachmentRepository + * @extends {BaseRepository} + */ +export class CritterAttachmentRepository extends BaseRepository { + /** + * Get Critter Capture Attachment S3 key. + * + * Note: Joining on survey_id for security purposes. + * + * @param {number} surveyId - Survey ID + * @param {number} attachmentId - Critter Capture Attachment ID + * @return {*} {Promise} + */ + async getCritterCaptureAttachmentS3Key(surveyId: number, attachmentId: number): Promise { + const sqlStatement = SQL` + SELECT + key + FROM critter_capture_attachment cc + JOIN critter c + ON c.critter_id = cc.critter_id + JOIN survey s + ON s.survey_id = c.survey_id + WHERE cc.critter_capture_attachment_id = ${attachmentId} + AND s.survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, z.object({ key: z.string() })); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get critter capture attachment signed URL', [ + 'AttachmentRepository->getCritterCaptureAttachmentS3Key', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0].key; + } + /** + * Upsert Critter Capture Attachment record. + * + * @return {*} {Promise<{ critter_capture_attachment_id: number; key: string }>} + * @memberof AttachmentRepository + */ + async upsertCritterCaptureAttachment( + payload: CritterCaptureAttachmentPayload + ): Promise<{ critter_capture_attachment_id: number; key: string }> { + const sqlStatement = SQL` + INSERT INTO critter_capture_attachment ( + critter_id, + critterbase_capture_id, + file_name, + file_size, + file_type, + key + ) + VALUES ( + ${payload.critter_id}, + ${payload.critterbase_capture_id}, + ${payload.file_name}, + ${payload.file_size}, + ${ATTACHMENT_TYPE.OTHER}, + ${payload.key} + ) + ON CONFLICT (critter_id, critterbase_capture_id, file_name) + DO UPDATE SET + file_name = ${payload.file_name}, + file_size = ${payload.file_size} + RETURNING + critter_capture_attachment_id, + key; + `; + + const response = await this.connection.sql( + sqlStatement, + z.object({ critter_capture_attachment_id: z.number(), key: z.string() }) + ); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to upsert critter capture attachment data', [ + 'AttachmentRepository->upsertCritterCaptureAttachment', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Insert Critter Mortality Attachment record. + * + * @return {*} {Promise<{ critter_mortality_attachment_id: number; key: string }>} + * @memberof AttachmentRepository + */ + async upsertCritterMortalityAttachment( + payload: CritterMortalityAttachmentPayload + ): Promise<{ critter_mortality_attachment_id: number; key: string }> { + const sqlStatement = SQL` + INSERT INTO critter_capture_attachment ( + critter_id, + critterbase_capture_id, + file_name, + file_size, + file_type, + key + ) + VALUES ( + ${payload.critter_id}, + ${payload.critterbase_mortality_id}, + ${payload.file_name}, + ${payload.file_size}, + ${ATTACHMENT_TYPE.OTHER}, + ${payload.key} + ) + ON CONFLICT (critter_id, critterbase_mortality_id, file_name) + DO UPDATE SET + file_name = ${payload.file_name}, + file_size = ${payload.file_size} + RETURNING + critter_mortality_attachment_id, + key; + `; + + const response = await this.connection.sql( + sqlStatement, + z.object({ critter_mortality_attachment_id: z.number(), key: z.string() }) + ); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to upsert critter mortality attachment data', [ + 'AttachmentRepository->insertCritterMortalityAttachment', + 'rows was null or undefined, expected rows != null' + ]); + } + + return response.rows[0]; + } + + /** + * Find all Attachments for a Critterbase Capture ID. + * + * @param {number} surveyId - Survey ID + * @param {string} critterbaseCaptureId - Critterbase Capture ID + * @return {*} {Promise} + */ + async findAllCritterCaptureAttachments( + surveyId: number, + critterbaseCaptureId: string + ): Promise { + const sqlStatement = SQL` + SELECT cc.* + FROM critter_capture_attachment cc + INNER JOIN critter c + ON c.critter_id = cc.critter_id + INNER JOIN survey s + ON s.survey_id = c.survey_id + WHERE cc.critterbase_capture_id = ${critterbaseCaptureId} + AND s.survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, CritterCaptureAttachmentModel); + + return response.rows; + } + + /** + * Find Critter Capture Attachments by Critter ID. + * + * @param {number} critterId - SIMS Critter ID + * @return {*} {Promise} + * @memberof CritterAttachmentRepository + */ + async findCaptureAttachmentsByCritterId(critterId: number): Promise { + const sqlStatement = SQL` + SELECT + critter_capture_attachment_id, + uuid, + critter_id, + critterbase_capture_id, + file_type, + file_name, + file_size, + title, + description, + key + FROM critter_capture_attachment + WHERE critter_id = ${critterId}; + `; + + const response = await this.connection.sql(sqlStatement, CritterCaptureAttachmentRecord); + + return response.rows; + } + + /** + * Delete Critter Capture Attachments by ID. + * + * Note: Joining on survey_id for security purposes. + * + * @param {number} surveyId - Survey ID + * @param {number[]} deleteIds - Critter Capture Attachment ID's + * @return {*} {Promise} List of S3 keys that were deleted + */ + async deleteCritterCaptureAttachments(surveyId: number, deleteIds: number[]): Promise { + const knex = getKnex(); + + const queryBuilder = knex + .queryBuilder() + .del() + .from('critter_capture_attachment as cc') + .join('critter as c', 'c.critter_id', 'cc.critter_id') + .join('survey as s', 's.survey_id', 'c.survey_id') + .whereIn('cc.critter_capture_attachment_id', deleteIds) + .andWhere('s.survey_id', surveyId) + .returning('cc.key'); + + const response = await this.connection.knex(queryBuilder, z.object({ key: z.string() })); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to delete critter capture attachments', [ + 'AttachmentRepository->deleteCritterCaptureAttachments', + 'response was null or undefined, expected response != null' + ]); + } + + return response.rows.map((row) => row.key); + } +} diff --git a/api/src/repositories/markdown-repository.test.ts b/api/src/repositories/markdown-repository.test.ts new file mode 100644 index 0000000000..0453b5d68b --- /dev/null +++ b/api/src/repositories/markdown-repository.test.ts @@ -0,0 +1,123 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { MarkdownRepository } from './markdown-repository'; // Adjust the import based on your structure + +chai.use(sinonChai); + +describe('MarkdownRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getMarkdownByTypeName', () => { + it('should return a markdown object for a given type name', async () => { + const mockQueryResponse = { + rowCount: 1, + rows: [{ markdown_id: 1, markdown_type_id: 1, data: 'Sample markdown data', participated: false }] + } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const markdownRepository = new MarkdownRepository(mockDBConnection); + const queryObject = { system_user_id: 1, markdown_type_name: 'example' }; + + const response = await markdownRepository.getMarkdownByTypeName(queryObject); + + expect(response).to.eql(mockQueryResponse.rows[0]); + }); + }); + + describe('updateScore', () => { + it('should update the score and return the new score', async () => { + const mockQueryResponse = { + rowCount: 1, + rows: [{ score: 5 }] + } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const markdownRepository = new MarkdownRepository(mockDBConnection); + const markdownId = 1; + const delta = 1; + + const response = await markdownRepository.updateScore(markdownId, delta); + + expect(response).to.eql({ score: 5 }); + }); + + it('should not update the score if the user has already participated', async () => { + const mockQueryResponse = { + rowCount: 0, + rows: [] + } as unknown as QueryResult; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves(mockQueryResponse) + }); + + const markdownRepository = new MarkdownRepository(mockDBConnection); + const markdownId = 1; + const delta = 1; + + const response = await markdownRepository.updateScore(markdownId, delta); + + expect(response).to.be.undefined; + }); + }); + + describe('getUserParticipation', () => { + it('should get user participation successfully', async () => { + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves({ rows: [1] }) + }); + + const markdownRepository = new MarkdownRepository(mockDBConnection); + const markdownId = 1; + const systemUserId = 2; + + const response = await markdownRepository.getUserParticipation(markdownId, systemUserId); + + expect(response).to.equal(1); + }); + }); + + describe('insertUserParticipation', () => { + it('should insert user participation successfully', async () => { + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves({ rows: [1] }) + }); + + const markdownRepository = new MarkdownRepository(mockDBConnection); + const markdownId = 1; + const systemUserId = 2; + + const response = await markdownRepository.insertUserParticipation(markdownId, systemUserId); + + expect(response).to.equal(1); + }); + + it('should not insert if participation already exists', async () => { + const mockMarkdownUserId = 1; + + const mockDBConnection = getMockDBConnection({ + sql: sinon.stub().resolves({ rowCount: 1, rows: [mockMarkdownUserId] }) + }); + + const markdownRepository = new MarkdownRepository(mockDBConnection); + const markdownId = 1; + const systemUserId = 2; + + const response = await markdownRepository.insertUserParticipation(markdownId, systemUserId); + + expect(response).to.equal(mockMarkdownUserId); + }); + }); +}); diff --git a/api/src/repositories/markdown-repository.ts b/api/src/repositories/markdown-repository.ts new file mode 100644 index 0000000000..850fcef2e0 --- /dev/null +++ b/api/src/repositories/markdown-repository.ts @@ -0,0 +1,113 @@ +import SQL from 'sql-template-strings'; +import { MarkdownUserRecord } from '../database-models/markdown_user'; +import { MarkdownObject, MarkdownQueryObject } from '../models/markdown-view'; +import { BaseRepository } from './base-repository'; + +/** + * A repository class for managing markdown data for help dialogs. + * + * @export + * @class MarkdownRepository + * @extends {BaseRepository} + */ +export class MarkdownRepository extends BaseRepository { + /** + * Gets the active markdown record for a given markdown type + * + * @param {MarkdownQueryObject} MarkdownQueryObject + * @return {*} {Promise} + * @memberof MarkdownRepository + */ + async getMarkdownByTypeName(MarkdownQueryObject: MarkdownQueryObject): Promise { + const sqlStatement = SQL` + SELECT + m.markdown_id, + m.markdown_type_id, + m.data, + CASE WHEN mu.markdown_user_id IS NULL THEN FALSE + ELSE TRUE END AS participated + FROM + markdown m + LEFT JOIN markdown_type mt ON mt.markdown_type_id = m.markdown_type_id + LEFT JOIN markdown_user mu ON mu.markdown_id = m.markdown_id AND mu.system_user_id = ${MarkdownQueryObject.system_user_id} + WHERE + mt.name = ${MarkdownQueryObject.markdown_type_name} + AND m.record_end_date IS NULL + ; + `; + + const response = await this.connection.sql(sqlStatement, MarkdownObject); + + return response.rows[0]; + } + + /** + * Update the score of a markdown record. + * + * @param {number} markdownId + * @param {number} delta + * @return {*} {Promise} + * @memberof MarkdownRepository + */ + async updateScore(markdownId: number, delta: number): Promise { + const sqlStatement = SQL` + UPDATE markdown + SET score = score + ${delta} + WHERE markdown_id = ${markdownId} + RETURNING score; + `; + + const response = await this.connection.sql(sqlStatement); + + return response.rows[0]; + } + + /** + * Gets a participation record for a given markdown record and system user id, to check whether a user has already scored a markdown record + * + * @param {number} markdownId + * @param {number} systemUserId + * @return {*} {Promise} + * @memberof MarkdownRepository + */ + async getUserParticipation(markdownId: number, systemUserId: number): Promise { + const sqlStatement = SQL` + SELECT + markdown_user_id, + system_user_id, + markdown_id + FROM + markdown_user + WHERE + markdown_id = ${markdownId} + AND + system_user_id = ${systemUserId}; + `; + + const response = await this.connection.sql(sqlStatement, MarkdownUserRecord); + + return response.rows?.[0] ?? null; + } + + /** + * Insert a record indicating that the user has scored the given markdown record + * + * @param {number} markdownId + * @param {number} systemUserId + * @return {*} {Promise} + * @memberof MarkdownRepository + */ + async insertUserParticipation(markdownId: number, systemUserId: number): Promise { + const sqlStatement = SQL` + INSERT INTO + markdown_user (markdown_id, system_user_id) + VALUES + (${markdownId}, ${systemUserId}) + RETURNING markdown_user_id; + `; + + const response = await this.connection.sql(sqlStatement); + + return response.rows[0]; + } +} diff --git a/api/src/repositories/observation-repository/observation-repository.ts b/api/src/repositories/observation-repository/observation-repository.ts index 094ba7374a..e35472f3fa 100644 --- a/api/src/repositories/observation-repository/observation-repository.ts +++ b/api/src/repositories/observation-repository/observation-repository.ts @@ -31,11 +31,11 @@ export const ObservationRecord = z.object({ survey_sample_site_id: z.number().nullable(), survey_sample_method_id: z.number().nullable(), survey_sample_period_id: z.number().nullable(), - latitude: z.number(), - longitude: z.number(), + latitude: z.number().nullable(), + longitude: z.number().nullable(), count: z.number(), - observation_time: z.string(), - observation_date: z.string(), + observation_time: z.string().nullable(), + observation_date: z.string().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), @@ -82,6 +82,7 @@ const ObservationSubcountQuantitativeEnvironmentObject = ObservationSubCountQuan const ObservationSubcountObject = z.object({ observation_subcount_id: ObservationSubCountRecord.shape.observation_subcount_id, observation_subcount_sign_id: ObservationSubCountRecord.shape.observation_subcount_sign_id, + comment: ObservationSubCountRecord.shape.comment, subcount: ObservationSubCountRecord.shape.subcount, qualitative_measurements: z.array(ObservationSubcountQualitativeMeasurementObject), quantitative_measurements: z.array(ObservationSubcountQuantitativeMeasurementObject), @@ -319,10 +320,10 @@ export class ObservationRepository extends BaseRepository { observation.survey_sample_method_id ?? 'NULL', observation.survey_sample_period_id ?? 'NULL', observation.count, - observation.latitude, - observation.longitude, - `'${observation.observation_date}'`, - `'${observation.observation_time}'`, + observation.latitude ?? 'NULL', + observation.longitude ?? 'NULL', + observation.observation_date ? `'${observation.observation_date}'` : 'NULL', + observation.observation_time ? `'${observation.observation_time}'` : 'NULL', observation.itis_tsn ?? 'NULL', observation.itis_scientific_name ? `'${observation.itis_scientific_name}'` : 'NULL' ].join(', ')})`; @@ -372,6 +373,9 @@ export class ObservationRepository extends BaseRepository { knex.raw("JSON_BUILD_OBJECT('type', 'Point', 'coordinates', JSON_BUILD_ARRAY(longitude, latitude)) as geometry") ) .from('survey_observation') + // TODO: For observations without lat/lon, get a location from the sampling site? + .whereNotNull('latitude') + .whereNotNull('longitude') .where('survey_id', surveyId); const response = await this.connection.knex(query, ObservationGeometryRecord); diff --git a/api/src/repositories/observation-repository/utils.test.ts b/api/src/repositories/observation-repository/utils.test.ts index eff4a1f0e7..aadaec28ce 100644 --- a/api/src/repositories/observation-repository/utils.test.ts +++ b/api/src/repositories/observation-repository/utils.test.ts @@ -52,6 +52,7 @@ describe('Utils', () => { json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, + 'comment', observation_subcount.comment, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), @@ -112,6 +113,7 @@ describe('Utils', () => { json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, + 'comment', observation_subcount.comment, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), @@ -182,6 +184,7 @@ describe('Utils', () => { json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, + 'comment', observation_subcount.comment, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), diff --git a/api/src/repositories/observation-repository/utils.ts b/api/src/repositories/observation-repository/utils.ts index 1342b5484a..dcf5512973 100644 --- a/api/src/repositories/observation-repository/utils.ts +++ b/api/src/repositories/observation-repository/utils.ts @@ -253,6 +253,7 @@ export function getSurveyObservationsBaseQuery( json_agg(json_build_object( 'observation_subcount_id', observation_subcount.observation_subcount_id, 'observation_subcount_sign_id', observation_subcount.observation_subcount_sign_id, + 'comment', observation_subcount.comment, 'subcount', subcount, 'qualitative_measurements', COALESCE(w_qualitative_measurements.qualitative_measurements, '[]'::json), 'quantitative_measurements', COALESCE(w_quantitative_measurements.quantitative_measurements, '[]'::json), diff --git a/api/src/repositories/observation-subcount-environment-repository.ts b/api/src/repositories/observation-subcount-environment-repository.ts index c68c2d28a1..fe138034e7 100644 --- a/api/src/repositories/observation-subcount-environment-repository.ts +++ b/api/src/repositories/observation-subcount-environment-repository.ts @@ -354,10 +354,19 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { 'environment_qualitative.environment_qualitative_id' ); + const searchConditions = []; + for (const searchTerm of searchTerms) { - queryBuilder - .where('environment_qualitative.name', 'ILIKE', `%${searchTerm}%`) - .orWhere('environment_qualitative.description', 'ILIKE', `%${searchTerm}%`); + searchConditions.push( + knex.raw('environment_qualitative.name ILIKE ? OR environment_qualitative.description ILIKE ?', [ + `%${searchTerm}%`, + `%${searchTerm}%` + ]) + ); + } + + if (searchConditions.length > 0) { + queryBuilder.whereRaw(searchConditions.join(' OR ')); } queryBuilder.groupBy( @@ -381,7 +390,9 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { async findQuantitativeEnvironmentTypeDefinitions( searchTerms: string[] ): Promise { - const queryBuilder = getKnex() + const knex = getKnex(); + + const queryBuilder = knex .select( 'environment_quantitative.environment_quantitative_id', 'environment_quantitative.name', @@ -392,10 +403,19 @@ export class ObservationSubCountEnvironmentRepository extends BaseRepository { ) .from('environment_quantitative'); + const searchConditions = []; + for (const searchTerm of searchTerms) { - queryBuilder - .where('environment_quantitative.name', 'ILIKE', `%${searchTerm}%`) - .orWhere('environment_quantitative.description', 'ILIKE', `%${searchTerm}%`); + searchConditions.push( + knex.raw('environment_quantitative.name ILIKE ? OR environment_quantitative.description ILIKE ?', [ + `%${searchTerm}%`, + `%${searchTerm}%` + ]) + ); + } + + if (searchConditions.length > 0) { + queryBuilder.whereRaw(searchConditions.join(' OR ')); } const response = await this.connection.knex(queryBuilder, QuantitativeEnvironmentTypeDefinition); diff --git a/api/src/repositories/sample-location-repository.test.ts b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts similarity index 83% rename from api/src/repositories/sample-location-repository.test.ts rename to api/src/repositories/sample-location-repository/sample-location-repository.test.ts index 58fb9fa2f1..e1cbae4866 100644 --- a/api/src/repositories/sample-location-repository.test.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.test.ts @@ -3,8 +3,8 @@ import { describe } from 'mocha'; import { QueryResult } from 'pg'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { getMockDBConnection } from '../__mocks__/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { getMockDBConnection } from '../../__mocks__/db'; import { InsertSampleSiteRecord, SampleLocationRepository, UpdateSampleSiteRecord } from './sample-location-repository'; chai.use(sinonChai); @@ -82,6 +82,37 @@ describe('SampleLocationRepository', () => { }); }); + describe('getBasicSurveySampleLocationsBySiteIds', () => { + it('should successfully return sampling location records with basic data', async () => { + const mockRows = [{ survey_sample_site_id: 1, name: '', sample_methods: [] }]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ knex: () => mockResponse }); + + const surveySampleSiteIds = [1, 2]; + const surveyId = 2; + + const repo = new SampleLocationRepository(dbConnectionObj); + const response = await repo.getBasicSurveySampleLocationsBySiteIds(surveyId, surveySampleSiteIds); + + expect(response).to.eql(mockRows); + }); + }); + + describe('getSampleLocationsGeometryBySurveyId', () => { + it('should return sample site geometries', async () => { + const mockRows = [{ survey_sample_site_id: 1 }]; + const mockResponse = { rows: mockRows, rowCount: 1 } as any as Promise>; + const dbConnectionObj = getMockDBConnection({ sql: sinon.stub().resolves(mockResponse) }); + + const surveyId = 2; + + const repo = new SampleLocationRepository(dbConnectionObj); + const response = await repo.getSampleLocationsGeometryBySurveyId(surveyId); + + expect(response).to.eql(mockRows); + }); + }); + describe('updateSampleSite', () => { it('should update the record and return a single row', async () => { const mockRow = {}; @@ -189,7 +220,7 @@ describe('SampleLocationRepository', () => { await repo.deleteSampleSiteRecord(mockSurveyId, surveySampleLocationId); } catch (error) { expect(dbConnectionObj.sql).to.have.been.calledOnce; - expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to delete survey block record'); + expect((error as ApiExecuteSQLError).message).to.be.eql('Failed to delete survey sample site record'); } }); }); diff --git a/api/src/repositories/sample-location-repository.ts b/api/src/repositories/sample-location-repository/sample-location-repository.ts similarity index 52% rename from api/src/repositories/sample-location-repository.ts rename to api/src/repositories/sample-location-repository/sample-location-repository.ts index e8731c44bd..c4c3fb5fdb 100644 --- a/api/src/repositories/sample-location-repository.ts +++ b/api/src/repositories/sample-location-repository/sample-location-repository.ts @@ -1,28 +1,47 @@ import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; -import { getKnex } from '../database/db'; -import { ApiExecuteSQLError } from '../errors/api-error'; -import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; -import { ApiPaginationOptions } from '../zod-schema/pagination'; -import { BaseRepository } from './base-repository'; -import { SampleBlockRecord, UpdateSampleBlockRecord } from './sample-blocks-repository'; -import { SampleMethodRecord, UpdateSampleMethodRecord } from './sample-method-repository'; -import { SamplePeriodRecord } from './sample-period-repository'; -import { SampleStratumRecord, UpdateSampleStratumRecord } from './sample-stratums-repository'; +import { MethodTechniqueRecord } from '../../database-models/method_technique'; +import { SurveyBlockRecord } from '../../database-models/survey_block'; +import { SurveySampleBlockRecord } from '../../database-models/survey_sample_block'; +import { SurveySampleMethodRecord } from '../../database-models/survey_sample_method'; +import { SurveySamplePeriodRecord } from '../../database-models/survey_sample_period'; +import { SurveySampleSiteModel, SurveySampleSiteRecord } from '../../database-models/survey_sample_site'; +import { SurveySampleStratumRecord } from '../../database-models/survey_sample_stratum'; +import { SurveyStratumRecord } from '../../database-models/survey_stratum'; +import { getKnex } from '../../database/db'; +import { ApiExecuteSQLError } from '../../errors/api-error'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../../models/sampling-locations-view'; +import { generateGeometryCollectionSQL } from '../../utils/spatial-utils'; +import { ApiPaginationOptions } from '../../zod-schema/pagination'; +import { BaseRepository } from '../base-repository'; +import { SampleBlockRecord, UpdateSampleBlockRecord } from '../sample-blocks-repository'; +import { UpdateSampleMethodRecord } from '../sample-method-repository'; +import { SampleStratumRecord, UpdateSampleStratumRecord } from '../sample-stratums-repository'; +import { + getSamplingLocationBaseQuery, + makeFindSamplingMethodBaseQuery, + makeFindSamplingPeriodBaseQuery, + makeFindSamplingSiteBaseQuery +} from './utils'; /** - * An aggregate record that includes a single sample site, all of its child sample methods, and for each child sample - * method, all of its child sample periods. Also includes any survey blocks or survey stratums that this site belongs to. + * An aggregate record of a sample site without spatial data, including all of the child sample methods, + * and for each child sample method, all of its child sample periods. Also includes any survey blocks or survey + * stratums that this site belongs to. */ -export const SampleLocationRecord = z.object({ +export const SampleLocationNonSpatialRecord = z.object({ survey_sample_site_id: z.number(), survey_id: z.number(), name: z.string(), description: z.string().nullable(), - geojson: z.any(), + geometry_type: z.string(), sample_methods: z.array( - SampleMethodRecord.pick({ + SurveySampleMethodRecord.pick({ survey_sample_method_id: true, survey_sample_site_id: true, description: true, @@ -40,7 +59,7 @@ export const SampleLocationRecord = z.object({ ) }), sample_periods: z.array( - SamplePeriodRecord.pick({ + SurveySamplePeriodRecord.pick({ survey_sample_period_id: true, survey_sample_method_id: true, start_date: true, @@ -73,40 +92,135 @@ export const SampleLocationRecord = z.object({ }) ) }); -export type SampleLocationRecord = z.infer; +export type SampleLocationNonSpatialRecord = z.infer; /** - * A survey_sample_site record. + * Basic sample location data retrieved for supplementary observations data */ -export const SampleSiteRecord = z.object({ +export const SampleLocationBasicRecord = z.object({ survey_sample_site_id: z.number(), - survey_id: z.number(), name: z.string(), - description: z.string().nullable(), - geometry: z.null(), - geography: z.any(), - geojson: z.any(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() + sample_methods: z.array( + SurveySampleMethodRecord.pick({ + survey_sample_method_id: true, + survey_sample_site_id: true, + method_response_metric_id: true + }).extend( + z.object({ + technique: z.object({ + method_technique_id: z.number(), + name: z.string() + }), + sample_periods: z.array( + SurveySamplePeriodRecord.pick({ + survey_sample_period_id: true, + survey_sample_method_id: true, + start_date: true, + start_time: true, + end_date: true, + end_time: true + }) + ) + }).shape + ) + ) }); -export type SampleSiteRecord = z.infer; +export type SampleLocationBasicRecord = z.infer; + +/** + * An aggregate record that includes a single sample site, its location, all of its child sample methods, and for each child sample + * method, all of its child sample periods. Also includes any survey blocks or survey stratums that this site belongs to. + */ +export const SampleLocationRecord = SampleLocationNonSpatialRecord.omit({ geometry_type: true }).extend({ + geojson: z.any() +}); +export type SampleLocationRecord = z.infer; + +/** + * A survey_sample_site geometry + */ +export const SampleSiteGeometryRecord = z.object({ + survey_sample_site_id: z.number(), + geojson: z.any() +}); +export type SampleSiteGeometryRecord = z.infer; /** * Insert object for a single sample site record. */ -export type InsertSampleSiteRecord = Pick; +export type InsertSampleSiteRecord = Pick; /** * Update object for a single sample site record. */ export type UpdateSampleSiteRecord = Pick< - SampleSiteRecord, + SurveySampleSiteRecord, 'survey_sample_site_id' | 'survey_id' | 'name' | 'description' | 'geojson' >; +export const FindSampleSiteRecord = SurveySampleSiteRecord.pick({ + survey_sample_site_id: true, + survey_id: true, + name: true, + description: true +}).extend({ + geometry_type: z.string(), + blocks: z.array( + SurveySampleBlockRecord.pick({ + survey_sample_block_id: true, + survey_sample_site_id: true, + survey_block_id: true + }).merge( + SurveyBlockRecord.pick({ + name: true, + description: true + }) + ) + ), + stratums: z.array( + SurveySampleStratumRecord.pick({ + survey_sample_stratum_id: true, + survey_sample_site_id: true, + survey_stratum_id: true + }).merge( + SurveyStratumRecord.pick({ + name: true, + description: true + }) + ) + ) +}); + +export type FindSampleSiteRecord = z.infer; + +export const FindSamplePeriodRecord = SurveySamplePeriodRecord.pick({ + survey_sample_period_id: true, + survey_sample_method_id: true, + start_date: true, + start_time: true, + end_date: true, + end_time: true +}) + .extend({ + sample_method: SurveySampleMethodRecord.pick({ + method_response_metric_id: true + }) + }) + .extend({ + method_technique: MethodTechniqueRecord.pick({ + method_technique_id: true, + name: true + }) + }) + .extend({ + sample_site: SurveySampleSiteRecord.pick({ + survey_sample_site_id: true, + name: true + }) + }); + +export type FindSamplePeriodRecord = z.infer; + /** * Update object for a sample site record, including all associated methods and periods. */ @@ -133,14 +247,26 @@ export class SampleLocationRepository extends BaseRepository { * Gets a paginated set of Sample Locations for the given survey for a given Survey * * @param {number} surveyId - * @return {*} {Promise} + * @param {{ + * keyword?: string; + * sampleSiteIds?: number[]; + * pagination?: ApiPaginationOptions; + * }} [options] + * @return {*} {Promise} * @memberof SampleLocationRepository */ async getSampleLocationsForSurveyId( surveyId: number, - pagination?: ApiPaginationOptions - ): Promise { + options?: { + keyword?: string; + sampleSiteIds?: number[]; + pagination?: ApiPaginationOptions; + } + ): Promise { + const { keyword, sampleSiteIds, pagination } = options || {}; + const knex = getKnex(); + const queryBuilder = knex .queryBuilder() .with('w_method_technique_attractant', (qb) => { @@ -246,7 +372,7 @@ export class SampleLocationRepository extends BaseRepository { 'sss.survey_id', 'sss.name', 'sss.description', - 'sss.geojson', + knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`), knex.raw(` COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, COALESCE(wssb.blocks, '[]'::json) as blocks, @@ -258,7 +384,18 @@ export class SampleLocationRepository extends BaseRepository { .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') .where('sss.survey_id', surveyId); - if (pagination) { + if (sampleSiteIds) { + // Filter results by sample site IDs + queryBuilder.whereIn('sss.survey_sample_site_id', sampleSiteIds); + } + + if (keyword) { + // Filter results by keyword + queryBuilder.andWhere((qb) => { + qb.orWhere('sss.name', 'ilike', `%${keyword}%`).orWhere('sss.description', 'ilike', `%${keyword}%`); + }); + } else if (pagination) { + // Filter results by pagination queryBuilder.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); if (pagination.sort && pagination.order) { @@ -266,7 +403,7 @@ export class SampleLocationRepository extends BaseRepository { } } - const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + const response = await this.connection.knex(queryBuilder, SampleLocationNonSpatialRecord); return response.rows; } @@ -305,10 +442,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { + async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { const sqlStatement = SQL` SELECT sss.* @@ -320,7 +457,7 @@ export class SampleLocationRepository extends BaseRepository { sss.survey_sample_site_id = ${surveySampleSiteId} `; - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to get sample site by ID', [ @@ -341,38 +478,42 @@ export class SampleLocationRepository extends BaseRepository { * @memberof SampleLocationService */ async getSurveySampleLocationBySiteId(surveyId: number, surveySampleSiteId: number): Promise { + const knex = getKnex(); + const queryBuilder = getSamplingLocationBaseQuery(knex) + .where('sss.survey_id', surveyId) + .where('sss.survey_sample_site_id', surveySampleSiteId); + + const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get sample site by ID', [ + 'SampleLocationRepository->getSurveySampleLocationBySiteId', + 'rowCount was < 1, expected rowCount > 0' + ]); + } + + return response.rows[0]; + } + + /** + * Gets basic data for survey sample sites for supplementary observations data + * + * @param {number} surveyId + * @param {number[]} surveySampleSiteIds + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getBasicSurveySampleLocationsBySiteIds( + surveyId: number, + surveySampleSiteIds: number[] + ): Promise { const knex = getKnex(); const queryBuilder = knex .queryBuilder() - .with('w_method_technique_attractant', (qb) => { - // Gather technique attractants - qb.select( - 'mta.method_technique_id', - knex.raw(` - json_agg(json_build_object( - 'attractant_lookup_id', mta.attractant_lookup_id - )) as attractants`) - ) - .from({ mta: 'method_technique_attractant' }) - .groupBy('mta.method_technique_id'); - }) .with('w_method_technique', (qb) => { - // Gather method techniques - qb.select( - 'mt.method_technique_id', - knex.raw(` - json_build_object( - 'method_technique_id', mt.method_technique_id, - 'name', mt.name, - 'description', mt.description, - 'attractants', COALESCE(wmta.attractants, '[]'::json) - ) as method_technique`) - ) - .from({ mt: 'method_technique' }) - .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + qb.select('mt.method_technique_id', 'mt.name').from({ mt: 'method_technique' }); }) .with('w_survey_sample_period', (qb) => { - // Aggregate sample periods into an array of objects qb.select( 'ssp.survey_sample_method_id', knex.raw(` @@ -380,8 +521,8 @@ export class SampleLocationRepository extends BaseRepository { 'survey_sample_period_id', ssp.survey_sample_period_id, 'survey_sample_method_id', ssp.survey_sample_method_id, 'start_date', ssp.start_date, - 'start_time', ssp.start_time, 'end_date', ssp.end_date, + 'start_time', ssp.start_time, 'end_time', ssp.end_time ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods`) ) @@ -389,18 +530,18 @@ export class SampleLocationRepository extends BaseRepository { .groupBy('ssp.survey_sample_method_id'); }) .with('w_survey_sample_method', (qb) => { - // Aggregate sample methods into an array of objects and include the corresponding sample periods qb.select( 'ssm.survey_sample_site_id', knex.raw(` json_agg(json_build_object( 'survey_sample_method_id', ssm.survey_sample_method_id, 'survey_sample_site_id', ssm.survey_sample_site_id, - - 'technique', wmt.method_technique, - 'description', ssm.description, - 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), - 'method_response_metric_id', ssm.method_response_metric_id + 'method_response_metric_id', ssm.method_response_metric_id, + 'technique', json_build_object( + 'method_technique_id', wmt.method_technique_id, + 'name', wmt.name + ), + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json) )) as sample_methods`) ) .from({ ssm: 'survey_sample_method' }) @@ -408,79 +549,223 @@ export class SampleLocationRepository extends BaseRepository { .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id') .groupBy('ssm.survey_sample_site_id'); }) - .with('w_survey_sample_block', (qb) => { - // Aggregate sample blocks into an array of objects - qb.select( - 'ssb.survey_sample_site_id', - knex.raw(` - json_agg(json_build_object( - 'survey_sample_block_id', ssb.survey_sample_block_id, - 'survey_sample_site_id', ssb.survey_sample_site_id, - 'survey_block_id', ssb.survey_block_id, - 'name', sb.name, - 'description', sb.description - )) as blocks`) - ) - .from({ ssb: 'survey_sample_block' }) - .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') - .groupBy('ssb.survey_sample_site_id'); - }) - .with('w_survey_sample_stratum', (qb) => { - // Aggregate sample stratums into an array of objects - qb.select( - 'ssst.survey_sample_site_id', - knex.raw(` - json_agg(json_build_object( - 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, - 'survey_sample_site_id', ssst.survey_sample_site_id, - 'survey_stratum_id', ssst.survey_stratum_id, - 'name', ss.name, - 'description', ss.description - )) as stratums`) - ) - .from({ ssst: 'survey_sample_stratum' }) - .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') - .groupBy('ssst.survey_sample_site_id'); - }) - // Fetch sample sites and include the corresponding sample methods, blocks, and stratums .select( 'sss.survey_sample_site_id', - 'sss.survey_id', 'sss.name', - 'sss.description', - 'sss.geojson', knex.raw(` - COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, - COALESCE(wssb.blocks, '[]'::json) as blocks, - COALESCE(wssst.stratums, '[]'::json) as stratums`) + COALESCE(wssm.sample_methods, '[]'::json) as sample_methods + `) ) .from({ sss: 'survey_sample_site' }) .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') - .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') - .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') .where('sss.survey_id', surveyId) - .where('sss.survey_sample_site_id', surveySampleSiteId); + .whereIn('sss.survey_sample_site_id', surveySampleSiteIds); - const response = await this.connection.knex(queryBuilder, SampleLocationRecord); + const response = await this.connection.knex(queryBuilder, SampleLocationBasicRecord); if (!response.rowCount) { - throw new ApiExecuteSQLError('Failed to get sample site by ID', [ - 'SampleLocationRepository->getSurveySampleSiteById', + throw new ApiExecuteSQLError('Failed to get sample sites by IDs', [ + 'SampleLocationRepository->getBasicSurveySampleLocationsBySiteIds', 'rowCount was < 1, expected rowCount > 0' ]); } - return response.rows[0]; + return response.rows; + } + + /** + * Gets geometry for sampling sites in the survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async getSampleLocationsGeometryBySurveyId(surveyId: number): Promise { + const sqlStatement = SQL` + SELECT + survey_sample_site_id, + geojson + FROM + survey_sample_site + WHERE + survey_id = ${surveyId}; + `; + + const response = await this.connection.sql(sqlStatement, SampleSiteGeometryRecord); + + return response.rows; + } + + /** + * Retrieve the list of sites that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {Promise} A promise resolving to the list of sites. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findSites( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = makeFindSamplingSiteBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(query, FindSampleSiteRecord); + + return response.rows; + } + + /** + * Retrieve the count of sites that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findSitesCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters + ): Promise { + const knex = getKnex(); + + const findSitesQuery = makeFindSamplingSiteBaseQuery(isUserAdmin, systemUserId, filterFields); + + const query = knex.from(findSitesQuery.as('fsq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + return response.rows[0].count; + } + + /** + * Retrieve the list of methods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {*} + * @memberof SampleLocationRepository + */ + async findMethods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + const query = makeFindSamplingMethodBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex( + query, + z.object({ + survey_sample_method_id: z.number(), + survey_sample_site_id: z.number(), + description: z.string().nullable(), // TODO NICK nullable? + method_response_metric_id: z.number(), + technique: z.object({ + method_technique_id: z.number(), + name: z.string(), + description: z.string().nullable(), // TODO NICK nullable? + attractants: z.array( + z.object({ + attractant_lookup_id: z.number() + }) + ) + }) + }) + ); + + return response.rows; + } + + /** + * Retrieve the list of periods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @param {ApiPaginationOptions} [pagination] The pagination options. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findPeriods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + const query = makeFindSamplingPeriodBaseQuery(isUserAdmin, systemUserId, filterFields); + + if (pagination) { + query.limit(pagination.limit).offset((pagination.page - 1) * pagination.limit); + + if (pagination.sort && pagination.order) { + query.orderBy(pagination.sort, pagination.order); + } + } + + const response = await this.connection.knex(query, FindSamplePeriodRecord); + + return response.rows; + } + + /** + * Retrieve the count of periods that the user has access to, based on filters and pagination options. + * + * @param {boolean} isUserAdmin Whether the user is an admin. + * @param {number | null} systemUserId The user's ID. + * @param {ISiteAdvancedFilters} filterFields The filter fields to apply. + * @return {*} {Promise} + * @memberof SampleLocationRepository + */ + async findPeriodsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters + ): Promise { + const knex = getKnex(); + + const findPeriodsQuery = makeFindSamplingPeriodBaseQuery(isUserAdmin, systemUserId, filterFields); + + const query = knex.from(findPeriodsQuery.as('fpq')).select(knex.raw('count(*)::integer as count')); + + const response = await this.connection.knex(query, z.object({ count: z.number() })); + + return response.rows[0].count; } /** * Updates a survey sample site record. * * @param {UpdateSampleSiteRecord} sample - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async updateSampleSite(sample: UpdateSampleSiteRecord): Promise { + async updateSampleSite(sample: UpdateSampleSiteRecord): Promise { const sql = SQL` UPDATE survey_sample_site @@ -502,7 +787,7 @@ export class SampleLocationRepository extends BaseRepository { RETURNING *;`); - const response = await this.connection.sql(sql, SampleSiteRecord); + const response = await this.connection.sql(sql, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to update sample location record', [ @@ -522,10 +807,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {InsertSampleSiteRecord} sampleSite - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async insertSampleSite(surveyId: number, sampleSite: InsertSampleSiteRecord): Promise { + async insertSampleSite(surveyId: number, sampleSite: InsertSampleSiteRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_site ( survey_id, @@ -559,7 +844,7 @@ export class SampleLocationRepository extends BaseRepository { *; `); - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample location', [ @@ -576,10 +861,10 @@ export class SampleLocationRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationRepository */ - async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { + async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { const sqlStatement = SQL` DELETE FROM survey_sample_site @@ -591,10 +876,10 @@ export class SampleLocationRepository extends BaseRepository { *; `; - const response = await this.connection.sql(sqlStatement, SampleSiteRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleSiteModel); if (!response?.rowCount) { - throw new ApiExecuteSQLError('Failed to delete survey block record', [ + throw new ApiExecuteSQLError('Failed to delete survey sample site record', [ 'SampleLocationRepository->deleteSampleSiteRecord', 'rows was null or undefined, expected rows != null' ]); diff --git a/api/src/repositories/sample-location-repository/utils.test.ts b/api/src/repositories/sample-location-repository/utils.test.ts new file mode 100644 index 0000000000..4fde53dbf6 --- /dev/null +++ b/api/src/repositories/sample-location-repository/utils.test.ts @@ -0,0 +1,37 @@ +import { expect } from 'chai'; +import { Knex } from 'knex'; +import { describe, it } from 'mocha'; +import { getKnex } from '../../database/db'; +import { getSamplingLocationBaseQuery } from './utils'; + +describe('getSamplingLocationBaseQuery', () => { + let knex: Knex; + + before(() => { + knex = getKnex(); + }); + + it('should return a query builder object', async () => { + const query = getSamplingLocationBaseQuery(knex); + + expect(query).to.be.an('object'); + expect(query.toString()).to.be.a('string'); + }); + + it('should select survey sample site fields correctly', async () => { + const query = getSamplingLocationBaseQuery(knex).toString(); + + expect(query).to.include('select "sss"."survey_sample_site_id", "sss"."survey_id"'); + expect(query).to.include("COALESCE(wssm.sample_methods, '[]'::json)"); + expect(query).to.include("COALESCE(wssb.blocks, '[]'::json)"); + expect(query).to.include("COALESCE(wssst.stratums, '[]'::json)"); + }); + + it('should join the correct tables', async () => { + const query = getSamplingLocationBaseQuery(knex).toString(); + + expect(query).to.include('left join "w_survey_sample_method" as "wssm"'); + expect(query).to.include('left join "w_survey_sample_block" as "wssb"'); + expect(query).to.include('left join "w_survey_sample_stratum" as "wssst"'); + }); +}); diff --git a/api/src/repositories/sample-location-repository/utils.ts b/api/src/repositories/sample-location-repository/utils.ts new file mode 100644 index 0000000000..4b5dd8de1b --- /dev/null +++ b/api/src/repositories/sample-location-repository/utils.ts @@ -0,0 +1,471 @@ +import { Knex } from 'knex'; +import { getKnex } from '../../database/db'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../../models/sampling-locations-view'; + +/** + * Get the base query for retrieving survey sample locations + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample locations + */ +export function getSamplingLocationBaseQuery(knex: Knex): Knex.QueryBuilder { + return ( + knex + .queryBuilder() + .with('w_method_technique_attractant', (qb) => { + // Gather technique attractants + qb.select( + 'mta.method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'attractant_lookup_id', mta.attractant_lookup_id + )) as attractants`) + ) + .from({ mta: 'method_technique_attractant' }) + .groupBy('mta.method_technique_id'); + }) + .with('w_method_technique', (qb) => { + // Gather method techniques + qb.select( + 'mt.method_technique_id', + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name, + 'description', mt.description, + 'attractants', COALESCE(wmta.attractants, '[]'::json) + ) as method_technique`) + ) + .from({ mt: 'method_technique' }) + .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + }) + .with('w_survey_sample_period', (qb) => { + // Aggregate sample periods into an array of objects + qb.select( + 'ssp.survey_sample_method_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_period_id', ssp.survey_sample_period_id, + 'survey_sample_method_id', ssp.survey_sample_method_id, + 'start_date', ssp.start_date, + 'start_time', ssp.start_time, + 'end_date', ssp.end_date, + 'end_time', ssp.end_time + ) ORDER BY ssp.start_date, ssp.start_time) as sample_periods`) + ) + .from({ ssp: 'survey_sample_period' }) + .groupBy('ssp.survey_sample_method_id'); + }) + .with('w_survey_sample_method', (qb) => { + // Aggregate sample methods into an array of objects and include the corresponding sample periods + qb.select( + 'ssm.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_method_id', ssm.survey_sample_method_id, + 'survey_sample_site_id', ssm.survey_sample_site_id, + + 'technique', wmt.method_technique, + 'description', ssm.description, + 'sample_periods', COALESCE(wssp.sample_periods, '[]'::json), + 'method_response_metric_id', ssm.method_response_metric_id + )) as sample_methods`) + ) + .from({ ssm: 'survey_sample_method' }) + .leftJoin('w_survey_sample_period as wssp', 'wssp.survey_sample_method_id', 'ssm.survey_sample_method_id') + .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id') + .groupBy('ssm.survey_sample_site_id'); + }) + .with('w_survey_sample_block', (qb) => { + // Aggregate sample blocks into an array of objects + qb.select( + 'ssb.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_block_id', ssb.survey_sample_block_id, + 'survey_sample_site_id', ssb.survey_sample_site_id, + 'survey_block_id', ssb.survey_block_id, + 'name', sb.name, + 'description', sb.description + )) as blocks`) + ) + .from({ ssb: 'survey_sample_block' }) + .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') + .groupBy('ssb.survey_sample_site_id'); + }) + .with('w_survey_sample_stratum', (qb) => { + // Aggregate sample stratums into an array of objects + qb.select( + 'ssst.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, + 'survey_sample_site_id', ssst.survey_sample_site_id, + 'survey_stratum_id', ssst.survey_stratum_id, + 'name', ss.name, + 'description', ss.description + )) as stratums`) + ) + .from({ ssst: 'survey_sample_stratum' }) + .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') + .groupBy('ssst.survey_sample_site_id'); + }) + // Fetch sample sites and include the corresponding sample methods, blocks, and stratums + .select( + 'sss.survey_sample_site_id', + 'sss.survey_id', + 'sss.name', + 'sss.description', + 'sss.geojson', + knex.raw(` + COALESCE(wssm.sample_methods, '[]'::json) as sample_methods, + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) + ) + .from({ sss: 'survey_sample_site' }) + .leftJoin('w_survey_sample_method as wssm', 'wssm.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id') + ); +} +/** + * Get the base query for retrieving survey sample sites. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample sites + */ +export function getSamplingSiteBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .with('w_survey_sample_block', (qb) => { + // Aggregate sample blocks into an array of objects + qb.select( + 'ssb.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_block_id', ssb.survey_sample_block_id, + 'survey_sample_site_id', ssb.survey_sample_site_id, + 'survey_block_id', ssb.survey_block_id, + 'name', sb.name, + 'description', sb.description + )) as blocks`) + ) + .from({ ssb: 'survey_sample_block' }) + .leftJoin('survey_block as sb', 'sb.survey_block_id', 'ssb.survey_block_id') + .groupBy('ssb.survey_sample_site_id'); + }) + .with('w_survey_sample_stratum', (qb) => { + // Aggregate sample stratums into an array of objects + qb.select( + 'ssst.survey_sample_site_id', + knex.raw(` + json_agg(json_build_object( + 'survey_sample_stratum_id', ssst.survey_sample_stratum_id, + 'survey_sample_site_id', ssst.survey_sample_site_id, + 'survey_stratum_id', ssst.survey_stratum_id, + 'name', ss.name, + 'description', ss.description + )) as stratums`) + ) + .from({ ssst: 'survey_sample_stratum' }) + .leftJoin('survey_stratum as ss', 'ss.survey_stratum_id', 'ssst.survey_stratum_id') + .groupBy('ssst.survey_sample_site_id'); + }) + .select( + 'sss.survey_sample_site_id', + 'sss.survey_id', + 'sss.name', + 'sss.description', + knex.raw(`sss.geojson->'geometry'->>'type' as geometry_type`), + knex.raw(` + COALESCE(wssb.blocks, '[]'::json) as blocks, + COALESCE(wssst.stratums, '[]'::json) as stratums`) + ) + .from({ sss: 'survey_sample_site' }) + .leftJoin('w_survey_sample_block as wssb', 'wssb.survey_sample_site_id', 'sss.survey_sample_site_id') + .leftJoin('w_survey_sample_stratum as wssst', 'wssst.survey_sample_site_id', 'sss.survey_sample_site_id'); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample sites, including blocks and stratums. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample sites + */ +export function makeFindSamplingSiteBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingSitesQuery = knex.queryBuilder(); + + // Add the base query + getSamplingSitesQuery.modify(getSamplingSiteBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingSitesQuery.whereIn('sss.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingSitesQuery.andWhere('sss.survey_id', filterFields.survey_id); + } + + if (filterFields.keyword) { + // Filter by keyword + getSamplingSitesQuery.where((subqueryBuilder) => { + subqueryBuilder + .orWhere('sss.name', 'ilike', `%${filterFields.keyword}%`) + .orWhere('sss.description', 'ilike', `%${filterFields.keyword}%`); + }); + } + + return getSamplingSitesQuery; +} + +/** + * Get the base query for retrieving survey sample methods, including the technique and attractants. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample methods + */ +export function getSamplingMethodBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .with('w_method_technique_attractant', (qb) => { + // Gather technique attractants + qb.select( + 'mta.method_technique_id', + knex.raw(` + json_agg(json_build_object( + 'attractant_lookup_id', mta.attractant_lookup_id + )) as attractants`) + ) + .from({ mta: 'method_technique_attractant' }) + .groupBy('mta.method_technique_id'); + }) + .with('w_method_technique', (qb) => { + // Gather method techniques + qb.select( + 'mt.method_technique_id', + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name, + 'description', mt.description, + 'attractants', COALESCE(wmta.attractants, '[]'::json) + ) as method_technique`) + ) + .from({ mt: 'method_technique' }) + .leftJoin('w_method_technique_attractant as wmta', 'wmta.method_technique_id', 'mt.method_technique_id'); + }) + .select( + 'ssm.survey_sample_method_id', + 'ssm.survey_sample_site_id', + 'ssm.description', + 'ssm.method_response_metric_id', + 'wmt.method_technique as technique' + ) + .from({ ssm: 'survey_sample_method' }) + .leftJoin('w_method_technique as wmt', 'wmt.method_technique_id', 'ssm.method_technique_id'); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample methods, including the technique and attractants. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample methods + */ +export function makeFindSamplingMethodBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingMethodsQuery = knex.queryBuilder(); + + // Add the base query + getSamplingMethodsQuery.modify(getSamplingMethodBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingMethodsQuery.whereIn('ssm.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingMethodsQuery.andWhere('ssm.survey_id', filterFields.survey_id); + } + + if (filterFields.sample_site_id) { + // Filter by a specific sample site id + getSamplingMethodsQuery.andWhere('ssm.survey_sample_site_id', filterFields.sample_site_id); + } + + if (filterFields.keyword) { + // Filter by keyword + getSamplingMethodsQuery.where((subqueryBuilder) => { + subqueryBuilder + .orWhere('ssm.description', 'ilike', `%${filterFields.keyword}%`) + .orWhere('wmt.technique->name', 'ilike', `%${filterFields.keyword}%`) + .orWhere('wmt.technique->description', 'ilike', `%${filterFields.keyword}%`); + }); + } + + return getSamplingMethodsQuery; +} + +/** + * Get the base query for retrieving survey sample periods. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample periods + */ +export function getSamplingPeriodBaseQuery(queryBuilder: Knex.QueryBuilder): Knex.QueryBuilder { + const knex = getKnex(); + + queryBuilder + .select( + 'ssp.survey_sample_period_id', + 'ssp.survey_sample_method_id', + 'ssp.start_date', + 'ssp.start_time', + 'ssp.end_date', + 'ssp.end_time', + knex.raw(` + json_build_object( + 'method_response_metric_id', ssm.method_response_metric_id + ) as sample_method`), + knex.raw(` + json_build_object( + 'method_technique_id', mt.method_technique_id, + 'name', mt.name + ) as method_technique`), + knex.raw(` + json_build_object( + 'survey_sample_site_id', sss.survey_sample_site_id, + 'name', sss.name + ) as sample_site`) + ) + .from({ ssp: 'survey_sample_period' }) + .join('survey_sample_method as ssm', 'ssm.survey_sample_method_id', 'ssp.survey_sample_method_id') + .join('method_technique as mt', 'mt.method_technique_id', 'ssm.method_technique_id') + .join('survey_sample_site as sss', 'sss.survey_sample_site_id', 'ssm.survey_sample_site_id'); + + return queryBuilder; +} + +/** + * Get the base query for retrieving survey sample periods. + * + * @param {Knex} knex The Knex instance. + * @return {*} {Knex.QueryBuilder} The base query for retrieving survey sample periods + */ +export function makeFindSamplingPeriodBaseQuery( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters +): Knex.QueryBuilder { + const knex = getKnex(); + + const getSurveyIdsQuery = knex.select(['survey_id']).from('survey'); + + // Ensure that users can only see observations that they are participating in, unless they are an administrator. + if (!isUserAdmin) { + getSurveyIdsQuery.whereIn('survey.project_id', (subqueryBuilder) => + subqueryBuilder + .select('project.project_id') + .from('project') + .leftJoin('project_participation', 'project_participation.project_id', 'project.project_id') + .where('project_participation.system_user_id', systemUserId) + ); + } + + if (filterFields.system_user_id) { + getSurveyIdsQuery.whereIn('p.project_id', (subQueryBuilder) => { + subQueryBuilder + .select('project_id') + .from('project_participation') + .where('system_user_id', filterFields.system_user_id); + }); + } + + const getSamplingPeriodsQuery = knex.queryBuilder(); + + // Add the base query + getSamplingPeriodsQuery.modify(getSamplingPeriodBaseQuery); + + // Filter by the survey ids the user has access to + getSamplingPeriodsQuery.whereIn('sss.survey_id', getSurveyIdsQuery); + + if (filterFields.survey_id) { + // Filter by a specific survey id + getSamplingPeriodsQuery.andWhere('sss.survey_id', filterFields.survey_id); + } + + if (filterFields.sample_site_id) { + // Filter by a specific sample site id + getSamplingPeriodsQuery.andWhere('ssp.survey_sample_site_id', filterFields.sample_site_id); + } + + if (filterFields.sample_method_id) { + // Filter by a specific sample method id + getSamplingPeriodsQuery.andWhere('ssp.survey_sample_method_id', filterFields.sample_method_id); + } + + return getSamplingPeriodsQuery; +} diff --git a/api/src/repositories/sample-method-repository.ts b/api/src/repositories/sample-method-repository.ts index 882044c70b..406c271f01 100644 --- a/api/src/repositories/sample-method-repository.ts +++ b/api/src/repositories/sample-method-repository.ts @@ -1,5 +1,6 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { SurveySampleMethodModel, SurveySampleMethodRecord } from '../database-models/survey_sample_method'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; @@ -9,7 +10,7 @@ import { InsertSamplePeriodRecord, UpdateSamplePeriodRecord } from './sample-per * Insert object for a single sample method record. */ export type InsertSampleMethodRecord = Pick< - SampleMethodRecord, + SurveySampleMethodRecord, 'survey_sample_site_id' | 'method_technique_id' | 'description' | 'method_response_metric_id' > & { sample_periods: InsertSamplePeriodRecord[] }; @@ -17,7 +18,7 @@ export type InsertSampleMethodRecord = Pick< * Update object for a single sample method record. */ export type UpdateSampleMethodRecord = Pick< - SampleMethodRecord, + SurveySampleMethodRecord, | 'survey_sample_method_id' | 'survey_sample_site_id' | 'method_technique_id' @@ -25,27 +26,10 @@ export type UpdateSampleMethodRecord = Pick< | 'method_response_metric_id' > & { sample_periods: UpdateSamplePeriodRecord[] }; -/** - * A survey_sample_method record. - */ -export const SampleMethodRecord = z.object({ - survey_sample_method_id: z.number(), - survey_sample_site_id: z.number(), - method_technique_id: z.number(), - method_response_metric_id: z.number(), - description: z.string(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() -}); -export type SampleMethodRecord = z.infer; - /** * A survey_sample_method detail object. */ -export const SampleMethodDetails = SampleMethodRecord.extend({ +export const SampleMethodDetails = SurveySampleMethodModel.extend({ technique: z.object({ method_technique_id: z.number(), name: z.string(), @@ -67,13 +51,13 @@ export class SampleMethodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ async getSampleMethodsForSurveySampleSiteId( surveyId: number, surveySampleSiteId: number - ): Promise { + ): Promise { const sql = SQL` SELECT * @@ -94,7 +78,7 @@ export class SampleMethodRepository extends BaseRepository { ; `; - const response = await this.connection.sql(sql, SampleMethodRecord); + const response = await this.connection.sql(sql, SurveySampleMethodModel); return response.rows; } @@ -122,10 +106,10 @@ export class SampleMethodRepository extends BaseRepository { * updates a survey Sample method. * * @param {UpdateSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { + async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { const sql = SQL` UPDATE survey_sample_method ssm SET @@ -158,10 +142,10 @@ export class SampleMethodRepository extends BaseRepository { * Inserts a new survey Sample method. * * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { + async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_method ( survey_sample_site_id, @@ -178,7 +162,7 @@ export class SampleMethodRepository extends BaseRepository { *; `; - const response = await this.connection.sql(sqlStatement, SampleMethodRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleMethodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample method', [ @@ -195,10 +179,10 @@ export class SampleMethodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodRepository */ - async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { + async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { const sqlStatement = SQL` DELETE FROM survey_sample_method USING survey_sample_site sss @@ -209,7 +193,7 @@ export class SampleMethodRepository extends BaseRepository { RETURNING survey_sample_method.*; `; - const response = await this.connection.sql(sqlStatement, SampleMethodRecord); + const response = await this.connection.sql(sqlStatement, SurveySampleMethodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample method', [ diff --git a/api/src/repositories/sample-period-repository.ts b/api/src/repositories/sample-period-repository.ts index 15279a85aa..2c95683688 100644 --- a/api/src/repositories/sample-period-repository.ts +++ b/api/src/repositories/sample-period-repository.ts @@ -1,5 +1,6 @@ import SQL from 'sql-template-strings'; import { z } from 'zod'; +import { SurveySamplePeriodModel, SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; import { getKnex } from '../database/db'; import { ApiExecuteSQLError } from '../errors/api-error'; import { BaseRepository } from './base-repository'; @@ -8,7 +9,7 @@ import { BaseRepository } from './base-repository'; * Insert object for a single sample period record. */ export type InsertSamplePeriodRecord = Pick< - SamplePeriodRecord, + SurveySamplePeriodRecord, 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' >; @@ -16,28 +17,10 @@ export type InsertSamplePeriodRecord = Pick< * Update object for a single sample period record. */ export type UpdateSamplePeriodRecord = Pick< - SamplePeriodRecord, + SurveySamplePeriodRecord, 'survey_sample_period_id' | 'survey_sample_method_id' | 'start_date' | 'end_date' | 'start_time' | 'end_time' >; -/** - * A survey_sample_period record. - */ -export const SamplePeriodRecord = z.object({ - survey_sample_period_id: z.number(), - survey_sample_method_id: z.number(), - start_date: z.string(), - end_date: z.string(), - start_time: z.string().nullable(), - end_time: z.string().nullable(), - create_date: z.string(), - create_user: z.number(), - update_date: z.string().nullable(), - update_user: z.number().nullable(), - revision_count: z.number() -}); -export type SamplePeriodRecord = z.infer; - /** * The full hierarchy of sample_* ids for a sample period. */ @@ -61,13 +44,13 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ async getSamplePeriodsForSurveyMethodId( surveyId: number, surveySampleMethodId: number - ): Promise { + ): Promise { const sql = SQL` SELECT ssp.* @@ -87,7 +70,7 @@ export class SamplePeriodRepository extends BaseRepository { sss.survey_id = ${surveyId} ORDER BY ssp.start_date, ssp.start_time;`; - const response = await this.connection.sql(sql, SamplePeriodRecord); + const response = await this.connection.sql(sql, SurveySamplePeriodModel); return response.rows; } @@ -140,10 +123,10 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {UpdateSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { + async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { const sql = SQL` UPDATE survey_sample_period AS ssp SET @@ -167,7 +150,7 @@ export class SamplePeriodRepository extends BaseRepository { `; - const response = await this.connection.sql(sql, SamplePeriodRecord); + const response = await this.connection.sql(sql, SurveySamplePeriodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to update sample period', [ @@ -183,10 +166,10 @@ export class SamplePeriodRepository extends BaseRepository { * Inserts a new survey Sample Period. * * @param {InsertSamplePeriodRecord} sample - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async insertSamplePeriod(sample: InsertSamplePeriodRecord): Promise { + async insertSamplePeriod(sample: InsertSamplePeriodRecord): Promise { const sqlStatement = SQL` INSERT INTO survey_sample_period ( survey_sample_method_id, @@ -204,7 +187,7 @@ export class SamplePeriodRepository extends BaseRepository { RETURNING *;`; - const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); + const response = await this.connection.sql(sqlStatement, SurveySamplePeriodModel); if (!response.rowCount) { throw new ApiExecuteSQLError('Failed to insert sample period', [ @@ -221,10 +204,10 @@ export class SamplePeriodRepository extends BaseRepository { * * @param {number} surveyId * @param {number} surveySamplePeriodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodRepository */ - async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { + async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { const sqlStatement = SQL` DELETE ssp @@ -245,7 +228,7 @@ export class SamplePeriodRepository extends BaseRepository { ; `; - const response = await this.connection.sql(sqlStatement, SamplePeriodRecord); + const response = await this.connection.sql(sqlStatement, SurveySamplePeriodModel); if (!response?.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample period', [ @@ -261,10 +244,10 @@ export class SamplePeriodRepository extends BaseRepository { * Deletes multiple Survey Sample Periods for a given array of period ids. * * @param {number[]} periodsToDelete an array of period ids to delete - * @returns {*} {Promise} an array of promises for the deleted periods + * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodRepository */ - async deleteSamplePeriods(surveyId: number, periodsToDelete: number[]): Promise { + async deleteSamplePeriods(surveyId: number, periodsToDelete: number[]): Promise { const knex = getKnex(); const sqlStatement = knex @@ -277,7 +260,7 @@ export class SamplePeriodRepository extends BaseRepository { .andWhere('survey_id', surveyId) .returning('ssp.*'); - const response = await this.connection.knex(sqlStatement, SamplePeriodRecord); + const response = await this.connection.knex(sqlStatement, SurveySamplePeriodModel); if (!response?.rowCount) { throw new ApiExecuteSQLError('Failed to delete sample periods', [ diff --git a/api/src/repositories/subcount-repository.test.ts b/api/src/repositories/subcount-repository.test.ts index c7c039f8bb..36165121f1 100644 --- a/api/src/repositories/subcount-repository.test.ts +++ b/api/src/repositories/subcount-repository.test.ts @@ -26,6 +26,7 @@ describe('SubCountRepository', () => { const mockSubcount: ObservationSubCountRecord = { observation_subcount_id: 1, survey_observation_id: 1, + comment: 'comment', subcount: 5, observation_subcount_sign_id: null, create_date: '1970-01-01', diff --git a/api/src/repositories/subcount-repository.ts b/api/src/repositories/subcount-repository.ts index 328a2ca412..8329111111 100644 --- a/api/src/repositories/subcount-repository.ts +++ b/api/src/repositories/subcount-repository.ts @@ -8,6 +8,7 @@ export const ObservationSubCountRecord = z.object({ survey_observation_id: z.number(), subcount: z.number().nullable(), observation_subcount_sign_id: z.number().nullable(), + comment: z.string().nullable(), create_date: z.string(), create_user: z.number(), update_date: z.string().nullable(), @@ -17,7 +18,7 @@ export const ObservationSubCountRecord = z.object({ export type ObservationSubCountRecord = z.infer; export type InsertObservationSubCount = Pick< ObservationSubCountRecord, - 'survey_observation_id' | 'subcount' | 'observation_subcount_sign_id' + 'survey_observation_id' | 'subcount' | 'observation_subcount_sign_id' | 'comment' >; export const SubCountEventRecord = z.object({ diff --git a/api/src/repositories/survey-block-repository.test.ts b/api/src/repositories/survey-block-repository.test.ts index e8920174f9..607c669fe2 100644 --- a/api/src/repositories/survey-block-repository.test.ts +++ b/api/src/repositories/survey-block-repository.test.ts @@ -23,6 +23,7 @@ describe('SurveyBlockRepository', () => { survey_id: 1, name: '', description: '', + geojson: '', create_date: '', create_user: 1, update_date: '', @@ -82,7 +83,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: 1, survey_id: 1, name: 'Updated name', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: 1, + survey_id: 1, + name: 'Updated name', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; const response = await repo.updateSurveyBlock(block); expect(response.survey_block_id).to.be.eql(1); expect(response.name).to.be.eql('Updated name'); @@ -98,7 +110,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: null, + survey_id: 1, + name: 'new', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; try { await repo.updateSurveyBlock(block); expect.fail(); @@ -131,7 +154,18 @@ describe('SurveyBlockRepository', () => { }); const repo = new SurveyBlockRepository(dbConnection); - const block: PostSurveyBlock = { survey_block_id: null, survey_id: 1, name: 'new', description: 'block' }; + const block: PostSurveyBlock = { + survey_block_id: null, + survey_id: 1, + name: 'new', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }; const response = await repo.insertSurveyBlock(block); expect(response.name).to.be.eql('new'); @@ -143,18 +177,29 @@ describe('SurveyBlockRepository', () => { rows: [], rowCount: 0 } as any as Promise>; + const dbConnection = getMockDBConnection({ sql: () => mockResponse }); + const repo = new SurveyBlockRepository(dbConnection); + try { const block = { survey_block_id: null, survey_id: 1, name: null, - description: null + description: null, + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } } as any as PostSurveyBlock; + await repo.insertSurveyBlock(block); + expect.fail(); } catch (error) { expect((error as any as ApiExecuteSQLError).message).to.be.eq('Failed to insert survey block'); diff --git a/api/src/repositories/survey-block-repository.ts b/api/src/repositories/survey-block-repository.ts index 83fe2acdba..863d3e44c0 100644 --- a/api/src/repositories/survey-block-repository.ts +++ b/api/src/repositories/survey-block-repository.ts @@ -1,6 +1,8 @@ +import { Feature } from 'geojson'; import SQL from 'sql-template-strings'; import { z } from 'zod'; import { ApiExecuteSQLError } from '../errors/api-error'; +import { generateGeometryCollectionSQL } from '../utils/spatial-utils'; import { BaseRepository } from './base-repository'; export interface PostSurveyBlock { @@ -8,24 +10,22 @@ export interface PostSurveyBlock { survey_id: number; name: string; description: string; + geojson: Feature; } // This describes the a row in the database for Survey Block export const SurveyBlockRecord = z.object({ survey_block_id: z.number(), + survey_id: z.number(), name: z.string(), description: z.string(), + geojson: z.any().nullable(), revision_count: z.number() }); export type SurveyBlockRecord = z.infer; // This describes the a row in the database for Survey Block -export const SurveyBlockRecordWithCount = z.object({ - survey_block_id: z.number(), - survey_id: z.number(), - name: z.string(), - description: z.string(), - revision_count: z.number(), +export const SurveyBlockRecordWithCount = SurveyBlockRecord.extend({ sample_block_count: z.number() }); export type SurveyBlockRecordWithCount = z.infer; @@ -52,6 +52,7 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, + sb.geojson, sb.revision_count, COUNT(ssb.survey_block_id)::integer AS sample_block_count FROM @@ -65,6 +66,7 @@ export class SurveyBlockRepository extends BaseRepository { sb.survey_id, sb.name, sb.description, + sb.geojson, sb.revision_count; `; @@ -86,15 +88,23 @@ export class SurveyBlockRepository extends BaseRepository { SET name = ${block.name}, description = ${block.description}, - survey_id=${block.survey_id} + survey_id = ${block.survey_id}, + geojson = ${JSON.stringify(block.geojson)}, + geography = public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(block.geojson)).append(`, 4326) + ) + ) WHERE survey_block_id = ${block.survey_block_id} RETURNING survey_block_id, + survey_id, name, description, + geojson, revision_count; - `; + `); const response = await this.connection.sql(sql, SurveyBlockRecord); if (!response.rowCount) { @@ -119,18 +129,29 @@ export class SurveyBlockRepository extends BaseRepository { INSERT INTO survey_block ( survey_id, name, - description + description, + geojson, + geography ) VALUES ( ${block.survey_id}, ${block.name}, - ${block.description} - ) + ${block.description}, + ${JSON.stringify(block.geojson)}, + public.geography( + public.ST_Force2D( + public.ST_SetSRID(`.append(generateGeometryCollectionSQL(block.geojson)).append(`, 4326) + ) + ) + ) RETURNING survey_block_id, + survey_id, name, description, + geojson, revision_count; - `; + `); + const response = await this.connection.sql(sql, SurveyBlockRecord); if (!response.rowCount) { diff --git a/api/src/repositories/technique-attribute-repository.ts b/api/src/repositories/technique-attribute-repository.ts index 41a34a0ae7..bec5542959 100644 --- a/api/src/repositories/technique-attribute-repository.ts +++ b/api/src/repositories/technique-attribute-repository.ts @@ -114,15 +114,20 @@ export class TechniqueAttributeRepository extends BaseRepository { 'method_lookup_attribute_qualitative_id', knex.raw(` json_agg(json_build_object( - 'method_lookup_attribute_qualitative_option_id', method_lookup_attribute_qualitative_option_id, - 'name', name, - 'description', description + 'method_lookup_attribute_qualitative_option_id', mlaqo.method_lookup_attribute_qualitative_option_id, + 'name', taqo.name, + 'description', taqo.description )) as options `) ) - .from('method_lookup_attribute_qualitative_option') - .where('record_end_date', null) - .groupBy('method_lookup_attribute_qualitative_id') + .from('method_lookup_attribute_qualitative_option as mlaqo') + .join( + 'technique_attribute_qualitative_option as taqo', + 'taqo.technique_attribute_qualitative_option_id', + 'mlaqo.technique_attribute_qualitative_option_id' + ) + .where('mlaqo.record_end_date', null) + .groupBy('mlaqo.method_lookup_attribute_qualitative_id') ) .with( 'w_qualitative_attributes', @@ -212,14 +217,19 @@ export class TechniqueAttributeRepository extends BaseRepository { 'method_lookup_attribute_qualitative_id', knex.raw(` json_agg(json_build_object( - 'method_lookup_attribute_qualitative_option_id', method_lookup_attribute_qualitative_option_id, - 'name', name, - 'description', description + 'method_lookup_attribute_qualitative_option_id', mlaqo.method_lookup_attribute_qualitative_option_id, + 'name', taqo.name, + 'description', taqo.description )) as options `) ) - .from('method_lookup_attribute_qualitative_option') - .where('record_end_date', null) + .from('method_lookup_attribute_qualitative_option as mlaqo') + .join( + 'technique_attribute_qualitative_option as taqo', + 'taqo.technique_attribute_qualitative_option_id', + 'mlaqo.technique_attribute_qualitative_option_id' + ) + .where('mlaqo.record_end_date', null) .groupBy('method_lookup_attribute_qualitative_id') ) .with( @@ -476,13 +486,13 @@ export class TechniqueAttributeRepository extends BaseRepository { defaultLog.debug({ label: 'deleteQualitativeAttributesForTechnique', methodTechniqueId }); const queryBuilder = getKnex() - .del() + .delete() .from('method_technique_attribute_qualitative as mtaq') .leftJoin('method_technique as mt', 'mt.method_technique_id', 'mtaq.method_technique_id') - .whereIn('method_technique_attribute_qualitative_id', methodTechniqueAttributeQualitativeIds) + .whereIn('mtaq.method_technique_attribute_qualitative_id', methodTechniqueAttributeQualitativeIds) .andWhere('mtaq.method_technique_id', methodTechniqueId) .andWhere('mt.survey_id', surveyId) - .returning('method_technique_attribute_qualitative.method_technique_attribute_qualitative_id'); + .returning('mtaq.method_technique_attribute_qualitative_id'); const response = await this.connection.knex( queryBuilder, @@ -518,13 +528,13 @@ export class TechniqueAttributeRepository extends BaseRepository { defaultLog.debug({ label: 'deleteQuantitativeAttributesForTechnique', methodTechniqueId }); const queryBuilder = getKnex() - .del() + .delete() .from('method_technique_attribute_quantitative as mtaq') .leftJoin('method_technique as mt', 'mt.method_technique_id', 'mtaq.method_technique_id') - .whereIn('method_technique_attribute_quantitative_id', methodTechniqueAttributeQuantitativeIds) + .whereIn('mtaq.method_technique_attribute_quantitative_id', methodTechniqueAttributeQuantitativeIds) .andWhere('mtaq.method_technique_id', methodTechniqueId) .andWhere('mt.survey_id', surveyId) - .returning('method_technique_attribute_quantitative.method_technique_attribute_quantitative_id'); + .returning('mtaq.method_technique_attribute_quantitative_id'); const response = await this.connection.knex( queryBuilder, diff --git a/api/src/repositories/vantage-mode-repository.test.ts b/api/src/repositories/vantage-mode-repository.test.ts new file mode 100644 index 0000000000..14f64babaf --- /dev/null +++ b/api/src/repositories/vantage-mode-repository.test.ts @@ -0,0 +1,60 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import { QueryResult } from 'pg'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { getMockDBConnection } from '../__mocks__/db'; +import { VantageMode, VantageModeRepository } from './vantage-mode-repository'; // Adjust paths as necessary + +chai.use(sinonChai); + +describe('VantageModeRepository', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getVantageModesByMethodLookupIds', () => { + it('should successfully return vantage modes for provided method lookup ids', async () => { + const mockVantageMode: VantageMode = { + vantage_mode_id: 1, + vantage_id: 101, + name: 'Mode A', + description: 'Description for mode A' + }; + + const mockResponse = { + rows: [mockVantageMode], + rowCount: 1 + } as any as Promise>; + + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repository = new VantageModeRepository(dbConnection); + const methodLookupIds = [1, 2, 3]; + + const response = await repository.getVantageModesByMethodLookupIds(methodLookupIds); + + expect(response).to.eql([mockVantageMode]); + }); + + it('should return an empty array if no vantage modes are found for provided method lookup ids', async () => { + const mockResponse = { + rows: [], + rowCount: 0 + } as any as Promise>; + + const dbConnection = getMockDBConnection({ + knex: () => mockResponse + }); + + const repository = new VantageModeRepository(dbConnection); + const methodLookupIds = [10, 20, 30]; + + const response = await repository.getVantageModesByMethodLookupIds(methodLookupIds); + + expect(response).to.eql([]); + }); + }); +}); diff --git a/api/src/repositories/vantage-mode-repository.ts b/api/src/repositories/vantage-mode-repository.ts new file mode 100644 index 0000000000..256fddd4c9 --- /dev/null +++ b/api/src/repositories/vantage-mode-repository.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { getKnex } from '../database/db'; +import { getLogger } from '../utils/logger'; +import { BaseRepository } from './base-repository'; + +const defaultLog = getLogger('repositories/technique-vantage-repository'); + +const VantageMode = z.object({ + vantage_mode_id: z.number(), + vantage_id: z.number(), + name: z.string(), + description: z.string() +}); + +export type VantageMode = z.infer; + +export class VantageModeRepository extends BaseRepository { + /** + * Get vantage modes for a set of method lookup ids + * + * @param {number[]} methodLookupIds + * @return {*} {Promise} + * @memberof VantageModeRepository + */ + async getVantageModesByMethodLookupIds(methodLookupIds: number[]): Promise { + defaultLog.debug({ label: 'getVantageModesByMethodLookupIds', methodLookupIds }); + + const knex = getKnex(); + + const queryBuilder = knex + .select('vantage_mode_id', 'vantage_id', 'description') + .from('vantage_mode as vm') + .join('method_vantage_mode as mvm', 'mvm.vantage_mode_id', 'vm.vantage_mode_id') + .whereIn('mvm.method_lookup_id', methodLookupIds); + + const response = await this.connection.knex(queryBuilder, VantageMode); + + return response.rows; + } +} diff --git a/api/src/services/administrative-activity-service.ts b/api/src/services/administrative-activity-service.ts index e1306e090c..873a28bf01 100644 --- a/api/src/services/administrative-activity-service.ts +++ b/api/src/services/administrative-activity-service.ts @@ -106,7 +106,7 @@ export class AdministrativeActivityService extends DBService { */ async sendAccessRequestNotificationEmailToAdmin(): Promise { const gcnotifyService = new GCNotifyService(); - const url = `${this.APP_HOST}/login?redirect=${encodeURIComponent('admin/users')}`; + const url = `${this.APP_HOST}/login?redirect=${encodeURIComponent('admin/manage/users')}`; const hrefUrl = `[click here.](${url})`; return gcnotifyService.sendEmailGCNotification(this.ADMIN_EMAIL, { diff --git a/api/src/services/alert-service.test.ts b/api/src/services/alert-service.test.ts new file mode 100644 index 0000000000..3c99559c01 --- /dev/null +++ b/api/src/services/alert-service.test.ts @@ -0,0 +1,130 @@ +import chai, { expect } from 'chai'; +import { afterEach, describe, it } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertSeverity } from '../models/alert-view'; +import { AlertRepository } from '../repositories/alert-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { AlertService } from './alert-service'; + +chai.use(sinonChai); + +describe('AlertService', () => { + let alertService: AlertService; + let mockAlertRepository: sinon.SinonStubbedInstance; + + afterEach(() => { + sinon.restore(); + }); + + beforeEach(() => { + const dbConnection = getMockDBConnection(); + alertService = new AlertService(dbConnection); + mockAlertRepository = sinon.createStubInstance(AlertRepository); + alertService.alertRepository = mockAlertRepository; // Inject the mocked repository + }); + + describe('getAlerts', () => { + it('returns an array of alerts', async () => { + const mockAlerts: IAlert[] = [ + { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + } + ]; + + mockAlertRepository.getAlerts.resolves(mockAlerts); + + const filterObject: IAlertFilterObject = {}; // Define your filter object as needed + + const response = await alertService.getAlerts(filterObject); + + expect(response).to.eql(mockAlerts); + expect(mockAlertRepository.getAlerts).to.have.been.calledOnceWith(filterObject); + }); + }); + + describe('getAlertById', () => { + it('returns a specific alert by its Id', async () => { + const mockAlert: IAlert = { + alert_id: 1, + name: 'Alert 1', + message: 'Message 1', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + }; + + mockAlertRepository.getAlertById.resolves(mockAlert); + + const response = await alertService.getAlertById(1); + + expect(response).to.eql(mockAlert); + expect(mockAlertRepository.getAlertById).to.have.been.calledOnceWith(1); + }); + }); + + describe('createAlert', () => { + it('creates an alert and returns its Id', async () => { + const mockAlertId = 1; + const mockAlert: IAlertCreateObject = { + name: 'New Alert', + message: 'New alert message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + record_end_date: null + }; + + mockAlertRepository.createAlert.resolves(mockAlertId); + + const response = await alertService.createAlert(mockAlert); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.createAlert).to.have.been.calledOnceWith(mockAlert); + }); + }); + + describe('updateAlert', () => { + it('updates an alert and returns its Id', async () => { + const mockAlertId = 1; + const mockAlert: IAlert = { + alert_id: mockAlertId, + name: 'Updated Alert', + message: 'Updated message', + alert_type_id: 1, + data: {}, + severity: 'error' as IAlertSeverity, + status: 'active', + record_end_date: null + }; + + mockAlertRepository.updateAlert.resolves(mockAlertId); + + const response = await alertService.updateAlert(mockAlert); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.updateAlert).to.have.been.calledOnceWith(mockAlert); + }); + }); + + describe('deleteAlert', () => { + it('deletes an alert and returns its Id', async () => { + const mockAlertId = 1; + mockAlertRepository.deleteAlert.resolves(mockAlertId); + + const response = await alertService.deleteAlert(mockAlertId); + + expect(response).to.equal(mockAlertId); + expect(mockAlertRepository.deleteAlert).to.have.been.calledOnceWith(mockAlertId); + }); + }); +}); diff --git a/api/src/services/alert-service.ts b/api/src/services/alert-service.ts new file mode 100644 index 0000000000..7c8174356a --- /dev/null +++ b/api/src/services/alert-service.ts @@ -0,0 +1,69 @@ +import { IDBConnection } from '../database/db'; +import { IAlert, IAlertCreateObject, IAlertFilterObject, IAlertUpdateObject } from '../models/alert-view'; +import { AlertRepository } from '../repositories/alert-repository'; +import { DBService } from './db-service'; + +export class AlertService extends DBService { + alertRepository: AlertRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.alertRepository = new AlertRepository(connection); + } + + /** + * Get all alert records, including deactivated alerts + * + * @param {IAlertFilterObject} filterObject + * @return {*} Promise + * @memberof AlertService + */ + async getAlerts(filterObject: IAlertFilterObject): Promise { + return this.alertRepository.getAlerts(filterObject); + } + + /** + * Get a specific alert by its ID + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertService + */ + async getAlertById(alertId: number): Promise { + return this.alertRepository.getAlertById(alertId); + } + + /** + * Create a system alert. + * + * @param {IAlertCreateObjectt} alert + * @return {*} Promise + * @memberof AlertService + */ + async createAlert(alert: IAlertCreateObject): Promise { + return this.alertRepository.createAlert(alert); + } + + /** + * Update a system alert. + * + * @param {IAlertUpdateObject} alert + * @return {*} Promise + * @memberof AlertService + */ + async updateAlert(alert: IAlertUpdateObject): Promise { + return this.alertRepository.updateAlert(alert); + } + + /** + * Delete a system alert. + * + * @param {number} alertId + * @return {*} Promise + * @memberof AlertService + */ + async deleteAlert(alertId: number): Promise { + return this.alertRepository.deleteAlert(alertId); + } +} diff --git a/api/src/services/attachment-service.ts b/api/src/services/attachment-service.ts index 017ea1c44e..ff5017d2e8 100644 --- a/api/src/services/attachment-service.ts +++ b/api/src/services/attachment-service.ts @@ -27,11 +27,11 @@ export interface IAttachmentType { } /** - * A repository class for accessing project and survey attachment data. + * A service class for accessing project and survey attachment data. * * @export - * @class AttachmentRepository - * @extends {BaseRepository} + * @class AttachmentService + * @extends {DBService} */ export class AttachmentService extends DBService { attachmentRepository: AttachmentRepository; diff --git a/api/src/services/code-service.test.ts b/api/src/services/code-service.test.ts index 9abf57fcee..d00351f3cc 100644 --- a/api/src/services/code-service.test.ts +++ b/api/src/services/code-service.test.ts @@ -45,7 +45,9 @@ describe('CodeService', () => { 'sample_methods', 'survey_progress', 'method_response_metrics', - 'observation_subcount_signs' + 'observation_subcount_signs', + 'alert_types', + 'vantages' ); }); }); diff --git a/api/src/services/code-service.ts b/api/src/services/code-service.ts index 715193a3ba..f8c828d741 100644 --- a/api/src/services/code-service.ts +++ b/api/src/services/code-service.ts @@ -44,7 +44,9 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + alert_types, + vantages ] = await Promise.all([ await this.codeRepository.getManagementActionType(), await this.codeRepository.getFirstNations(), @@ -65,7 +67,9 @@ export class CodeService extends DBService { await this.codeRepository.getSurveyProgress(), await this.codeRepository.getMethodResponseMetrics(), await this.codeRepository.getAttractants(), - await this.codeRepository.getObservationSubcountSigns() + await this.codeRepository.getObservationSubcountSigns(), + await this.codeRepository.getAlertTypes(), + await this.codeRepository.getVantages() ]); return { @@ -88,7 +92,9 @@ export class CodeService extends DBService { survey_progress, method_response_metrics, attractants, - observation_subcount_signs + observation_subcount_signs, + alert_types, + vantages }; } diff --git a/api/src/services/critter-attachment-service.test.ts b/api/src/services/critter-attachment-service.test.ts new file mode 100644 index 0000000000..880b8f11e3 --- /dev/null +++ b/api/src/services/critter-attachment-service.test.ts @@ -0,0 +1,104 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getMockDBConnection } from '../__mocks__/db'; +import { CritterAttachmentService } from './critter-attachment-service'; + +describe('CritterCaptureAttachmentService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getCritterCaptureAttachmentS3Key', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'getCritterCaptureAttachmentS3Key') + .resolves('key'); + + const result = await service.getCritterCaptureAttachmentS3Key(1, 2); + + expect(mockRepoMethod.calledOnceWithExactly(1, 2)).to.be.true; + expect(result).to.equal('key'); + }); + }); + + describe('upsertCritterCaptureAttachment', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'upsertCritterCaptureAttachment') + .resolves({ critter_capture_attachment_id: 1, key: 'KEY' }) + .resolves({ critter_capture_attachment_id: 1, key: 'KEY' }); + + const result = await service.upsertCritterCaptureAttachment({ + critter_id: 1, + critterbase_capture_id: '123e4567-e89b-12d3-a456-426614174000', + file_name: 'test.txt', + file_size: 1024, + key: 'KEY' + }); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly({ + critter_id: 1, + critterbase_capture_id: '123e4567-e89b-12d3-a456-426614174000', + file_name: 'test.txt', + file_size: 1024, + key: 'KEY' + }); + + expect(result).to.deep.equal({ critter_capture_attachment_id: 1, key: 'KEY' }); + }); + }); + + describe('deleteCritterCaptureAttachments', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'deleteCritterCaptureAttachments') + .resolves(['key']); + + const result = await service.deleteCritterCaptureAttachments(1, [1, 2]); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly(1, [1, 2]); + expect(result).to.deep.equal(['key']); + }); + }); + + describe('findAllCritterCaptureAttachments', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'findAllCritterCaptureAttachments') + .resolves([{ critter_capture_attachment_id: 1, key: 'key' }] as any[]); + + const result = await service.findAllCritterCaptureAttachments(1, '123e4567-e89b-12d3-a456-426614174000'); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly(1, '123e4567-e89b-12d3-a456-426614174000'); + expect(result).to.deep.equal([{ critter_capture_attachment_id: 1, key: 'key' }]); + }); + }); + + describe('findAllCritterAttachments', () => { + it('should call the repository method with correct params', async () => { + const connection = getMockDBConnection(); + const service = new CritterAttachmentService(connection); + + const mockRepoMethod = sinon + .stub(service.attachmentRepository, 'findCaptureAttachmentsByCritterId') + .resolves([{ critter_attachment_id: 1, key: 'key' }] as any[]); + + const result = await service.findAllCritterAttachments(1); + + expect(mockRepoMethod).to.have.been.calledOnceWithExactly(1); + expect(result).to.deep.equal({ captureAttachments: [{ critter_attachment_id: 1, key: 'key' }] }); + }); + }); +}); diff --git a/api/src/services/critter-attachment-service.ts b/api/src/services/critter-attachment-service.ts new file mode 100644 index 0000000000..a347794a1d --- /dev/null +++ b/api/src/services/critter-attachment-service.ts @@ -0,0 +1,106 @@ +import { + CritterCaptureAttachmentModel, + CritterCaptureAttachmentRecord +} from '../database-models/critter_capture_attachment'; +import { IDBConnection } from '../database/db'; +import { CritterAttachmentRepository } from '../repositories/critter-attachment-repository'; +import { + CritterCaptureAttachmentPayload, + CritterMortalityAttachmentPayload +} from '../repositories/critter-attachment-repository.interface'; +import { DBService } from './db-service'; + +/** + * Attachment service for accessing Critter Attachments. + * + * @export + * @class AttachmentService + * @extends {DBService} + */ +export class CritterAttachmentService extends DBService { + attachmentRepository: CritterAttachmentRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.attachmentRepository = new CritterAttachmentRepository(connection); + } + + /** + * Get Critter Capture Attachment S3 key. + * + * @param {number} surveyId - Survey ID + * @param {number} attachmentId - Critter Capture Attachment ID + * @return {*} {Promise} + */ + async getCritterCaptureAttachmentS3Key(surveyId: number, attachmentId: number): Promise { + return this.attachmentRepository.getCritterCaptureAttachmentS3Key(surveyId, attachmentId); + } + + /** + * Upsert Critter Capture Attachment. + * + * @param {CritterCaptureAttachmentPayload} payload + * @return {*} {Promise<{critter_capture_attachment_id: number; key: string}>} + */ + async upsertCritterCaptureAttachment( + payload: CritterCaptureAttachmentPayload + ): Promise<{ critter_capture_attachment_id: number; key: string }> { + return this.attachmentRepository.upsertCritterCaptureAttachment(payload); + } + + /** + * Delete Critter Capture Attachments. + * + * @param {number} surveyId - Survey ID + * @param {number[]} deleteIds - Critter Capture Attachment ID's + * @return {*} {Promise} List of keys that were deleted + * + */ + async deleteCritterCaptureAttachments(surveyId: number, deleteIds: number[]): Promise { + return this.attachmentRepository.deleteCritterCaptureAttachments(surveyId, deleteIds); + } + + /** + * Upsert Critter Mortality Attachment. + * + * @param {CritterMortalityAttachmentPayload} payload + * @return {*} {Promise<{critter_mortality_attachment_id: number; key: string}>} + */ + async upsertCritterMortalityAttachment( + payload: CritterMortalityAttachmentPayload + ): Promise<{ critter_mortality_attachment_id: number; key: string }> { + return this.attachmentRepository.upsertCritterMortalityAttachment(payload); + } + + /** + * Find all Attachments for a Critterbase Capture ID. + * + * @param {number} surveyId - Survey ID + * @param {string} critterbaseCaptureId - Critterbase Capture ID + * @return {*} {Promise} + */ + async findAllCritterCaptureAttachments( + surveyId: number, + critterbaseCaptureId: string + ): Promise { + return this.attachmentRepository.findAllCritterCaptureAttachments(surveyId, critterbaseCaptureId); + } + + /** + * Find all Attachments for a Critterbase Critter ID. + * + * TODO: Include mortality attachments. + * + * @param {number} critterId - SIMS Critter ID + * @return {*} {Promise<{captureAttachments: CritterCaptureAttachmentRecord[]}>} + */ + async findAllCritterAttachments( + critterId: number + ): Promise<{ captureAttachments: CritterCaptureAttachmentRecord[] }> { + const [captureAttachments] = await Promise.all([ + this.attachmentRepository.findCaptureAttachmentsByCritterId(critterId) + ]); + return { captureAttachments }; + } +} diff --git a/api/src/services/import-services/capture/import-captures-strategy.test.ts b/api/src/services/import-services/capture/import-captures-strategy.test.ts index b7df1d6eff..ec5d8059f3 100644 --- a/api/src/services/import-services/capture/import-captures-strategy.test.ts +++ b/api/src/services/import-services/capture/import-captures-strategy.test.ts @@ -22,18 +22,18 @@ describe('import-captures-service', () => { I1: { t: 's', v: 'RELEASE_LONGITUDE' }, J1: { t: 's', v: 'RELEASE_COMMENT' }, K1: { t: 's', v: 'CAPTURE_COMMENT' }, - A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A2: { t: 's', v: '2024-10-11' }, B2: { t: 's', v: 'Carl' }, C2: { t: 's', v: '10:10:10' }, D2: { t: 'n', w: '90', v: 90 }, E2: { t: 'n', w: '100', v: 100 }, - F2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + F2: { t: 's', v: '2024-10-10' }, G2: { t: 's', v: '9:09' }, H2: { t: 'n', w: '90', v: 90 }, I2: { t: 'n', w: '90', v: 90 }, J2: { t: 's', v: 'release' }, K2: { t: 's', v: 'capture' }, - A3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A3: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, B3: { t: 's', v: 'Carlita' }, D3: { t: 'n', w: '90', v: 90 }, E3: { t: 'n', w: '100', v: 100 }, diff --git a/api/src/services/import-services/critter/critter-header-configs.test.ts b/api/src/services/import-services/critter/critter-header-configs.test.ts new file mode 100644 index 0000000000..d965a70a2f --- /dev/null +++ b/api/src/services/import-services/critter/critter-header-configs.test.ts @@ -0,0 +1,311 @@ +import { expect } from 'chai'; +import xlsx from 'xlsx'; +import { CSVConfigUtils } from '../../../utils/csv-utils/csv-config-utils'; +import { CSVConfig } from '../../../utils/csv-utils/csv-config-validation.interface'; +import { NestedRecord } from '../../../utils/nested-record'; +import { + getCritterAliasCellValidator, + getCritterCollectionUnitCellSetter, + getCritterCollectionUnitCellValidator, + getCritterSexCellSetter, + getCritterSexCellValidator, + getWlhIDCellValidator +} from './critter-header-configs'; + +const mockConfig: CSVConfig = { + staticHeadersConfig: { + ALIAS: { aliases: [] } + }, + ignoreDynamicHeaders: true +}; + +describe('critter-header-configs', () => { + describe('getCritterAliasCellValidator', () => { + it('should return a single error when cell value is invalid', () => { + const badCellValues = [null, undefined, '', ' ', {}]; + for (const badCellValue of badCellValues) { + const critterAliasValidator = getCritterAliasCellValidator( + new Set(), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = critterAliasValidator({ cell: badCellValue, row: {}, header: 'ALIAS', rowIndex: 0 }); + + expect(result.length).to.be.equal(1); + } + }); + + it('should return an empty array if the cell is valid', () => { + const mockWorksheet = xlsx.utils.json_to_sheet([{ ALIAS: 'alias1' }, { ALIAS: 'alias2' }, { ALIAS: 'alias3' }]); + const surveyAliases = new Set(['alias1', 'alias2']); + const configUtils = new CSVConfigUtils(mockWorksheet, mockConfig); + + const critterAliasValidator = getCritterAliasCellValidator(surveyAliases, configUtils); + + const result = critterAliasValidator({ cell: 'alias4', row: {}, header: 'ALIAS', rowIndex: 0 }); + + expect(result).to.be.deep.equal([]); + }); + + it('should return single error when cell value already exists in survey aliases', () => { + const mockWorksheet = xlsx.utils.json_to_sheet([{ ALIAS: 'alias1' }, { ALIAS: 'alias2' }, { ALIAS: 'alias3' }]); + const surveyAliases = new Set(['alias1', 'alias2']); + const configUtils = new CSVConfigUtils(mockWorksheet, mockConfig); + + const critterAliasValidator = getCritterAliasCellValidator(surveyAliases, configUtils); + + const result = critterAliasValidator({ cell: 'alias1', row: {}, header: 'ALIAS', rowIndex: 0 }); + + expect(result).to.be.deep.equal([ + { + error: 'Critter alias already exists in the Survey', + solution: 'Update the alias to be unique' + } + ]); + }); + + it('should return single error when cell value already exists in row aliases', () => { + const mockWorksheet = xlsx.utils.json_to_sheet([{ ALIAS: 'alias1' }, { ALIAS: 'alias3' }, { ALIAS: 'alias3' }]); + const surveyAliases = new Set(['alias1', 'alias2']); + const configUtils = new CSVConfigUtils(mockWorksheet, mockConfig); + + const critterAliasValidator = getCritterAliasCellValidator(surveyAliases, configUtils); + + const result = critterAliasValidator({ cell: 'alias3', row: {}, header: 'ALIAS', rowIndex: 0 }); + + expect(result).to.be.deep.equal([ + { + error: 'Critter alias already exists in the CSV', + solution: 'Update the alias to be unique' + } + ]); + }); + }); + + describe('getCritterCollectionUnitCellValidator', () => { + it('should return an empty array if the cell is valid', () => { + const cellValidator = getCritterCollectionUnitCellValidator( + new NestedRecord({ + 1: { + HEADER: { + unit: 'uuid' + } + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const cellValues = ['unit', undefined]; + + for (const cell of cellValues) { + const result = cellValidator({ cell: cell, row: { ITIS_TSN: 1 }, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([]); + } + }); + + it('should return a single error when the tsn has no collection units', () => { + const cellValidator = getCritterCollectionUnitCellValidator( + new NestedRecord({ + 1: { + HEADER: { + unit: 'uuid' + } + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellValidator({ cell: 'unit', row: { ITIS_TSN: 2 }, header: 'HEADER', rowIndex: 0 }); + + expect(result[0].error).to.be.equal('Collection units not found for TSN: 2'); + }); + + it('should return a single error when collection unit header invalid', () => { + const cellValidator = getCritterCollectionUnitCellValidator( + new NestedRecord({ + 1: { + HEADER: { + unit: 'uuid' + } + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellValidator({ cell: 'unit', row: { ITIS_TSN: 1 }, header: 'HEADER2', rowIndex: 0 }); + + expect(result[0].error).to.be.equal('Invalid collection category header'); + }); + + it('should return a single error when collection unit value invalid', () => { + const cellValidator = getCritterCollectionUnitCellValidator( + new NestedRecord({ + 1: { + HEADER: { + unit: 'uuid' + } + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellValidator({ cell: 'unit2', row: { ITIS_TSN: 1 }, header: 'HEADER', rowIndex: 0 }); + + expect(result[0].error).to.be.equal('Invalid collection unit cell value'); + }); + }); + + describe('getCritterCollectionUnitSetter', () => { + it('should return undefined when cell value is falsy', () => { + const cellSetter = getCritterCollectionUnitCellSetter( + new NestedRecord(), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellSetter({ cell: '', row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.equal(undefined); + }); + + it('should return the uuid', () => { + const cellSetter = getCritterCollectionUnitCellSetter( + new NestedRecord({ + 1: { + HEADER: { + unit: 'uuid' + } + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellSetter({ cell: 'unit', row: { ITIS_TSN: 1 }, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.equal('uuid'); + }); + }); + + describe('getCritterSexCellValidator', () => { + it('should return an empty array if the cell is valid', () => { + const cellValidator = getCritterSexCellValidator( + new NestedRecord({ + 1: { + male: 'uuid' + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const cellValues = ['male', 'MALE', undefined]; + + for (const cell of cellValues) { + const result = cellValidator({ cell: cell, row: { ITIS_TSN: 1 }, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([]); + } + }); + + it('should return a single error when the cell value is invalid', () => { + const cellValidator = getCritterSexCellValidator( + new NestedRecord({ + 1: { + male: 'uuid' + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const cellValues = ['', 0]; + + for (const cell of cellValues) { + const result = cellValidator({ cell: cell, row: { ITIS_TSN: 1 }, header: 'HEADER', rowIndex: 0 }); + + expect(result.length).to.be.equal(1); + } + }); + + it('should return a single error when rowDictionary has no reference to TSN', () => { + const cellValidator = getCritterSexCellValidator( + new NestedRecord({ + 1: { + male: 'uuid' + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellValidator({ cell: 'male', row: { ITIS_TSN: 2 }, header: 'HEADER', rowIndex: 0 }); + + expect(result[0].error).to.be.equal('Sex is not a supported attribute for TSN: 2'); + }); + + it('should return a single error when rowDictionary has no reference to sex value', () => { + const cellValidator = getCritterSexCellValidator( + new NestedRecord({ + 1: { + male: 'uuid' + } + }), + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellValidator({ cell: 'maled', row: { ITIS_TSN: 1 }, header: 'HEADER', rowIndex: 0 }); + + expect(result[0].error).to.be.equal('Sex cell value is invalid'); + }); + }); + + describe('getCritterSexCellSetter', () => { + it('should return the uuid', () => { + const nestedRecord = new NestedRecord({ + 1: { + male: 'uuid' + } + }); + const cellSetter = getCritterSexCellSetter( + nestedRecord, + new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig) + ); + + const result = cellSetter({ cell: 'MALE', row: { ITIS_TSN: 1 }, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.equal('uuid'); + }); + }); + + describe('getWlhIDCellValidator', () => { + it('should return an empty array if the cell is valid', () => { + const wlhIDValidator = getWlhIDCellValidator(new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig)); + + const result = wlhIDValidator({ cell: '10-01111', row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([]); + }); + + it('should return no errors when cell is undefined', () => { + const wlhIDValidator = getWlhIDCellValidator(new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig)); + + const result = wlhIDValidator({ cell: undefined, row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([]); + }); + + it('should return single error when cell value does not pass regex', () => { + const wlhIDValidator = getWlhIDCellValidator(new CSVConfigUtils(xlsx.utils.json_to_sheet([]), mockConfig)); + + const badWlhIds = ['100111', '1-011111', '100-222', '21-']; + + badWlhIds.forEach((badWlhId) => { + const result = wlhIDValidator({ cell: badWlhId, row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([ + { + error: `Invalid Wildlife Health ID format`, + solution: `Update the Wildlife Health ID to match the expected format 'XX-XXXX'` + } + ]); + }); + }); + }); +}); diff --git a/api/src/services/import-services/critter/critter-header-configs.ts b/api/src/services/import-services/critter/critter-header-configs.ts new file mode 100644 index 0000000000..2ab817c9bd --- /dev/null +++ b/api/src/services/import-services/critter/critter-header-configs.ts @@ -0,0 +1,259 @@ +import { z } from 'zod'; +import { CSVConfigUtils } from '../../../utils/csv-utils/csv-config-utils'; +import { + CSVCellSetter, + CSVCellValidator, + CSVError, + CSVParams +} from '../../../utils/csv-utils/csv-config-validation.interface'; +import { validateZodCell } from '../../../utils/csv-utils/csv-header-configs'; +import { NestedRecord } from '../../../utils/nested-record'; +import { CritterCSVStaticHeader } from './import-critters-service'; + +/** + * Get the critter alias cell validator. + * + * Rules: + * 1. The cell can be a string with a length between 1 and 50 + * 2. The cell can be a number with a min value of 0 + * 3. The cell must be unique in the survey + * 4. The cell must be unique in the CSV + * + * @param {Set} surveyAliases The survey aliases. + * @param {CSVConfigUtils} configUtils The CSV config utils. + * @returns {*} {CSVCellValidator} The validate cell callback + */ +export const getCritterAliasCellValidator = ( + surveyAliases: Set, + configUtils: CSVConfigUtils +): CSVCellValidator => { + return (params: CSVParams) => { + const cellErrors = validateZodCell(params, z.union([z.string().trim().min(1).max(50), z.number().min(0)])); + const isAliasUnique = configUtils.isCellUnique('ALIAS', params.cell); + + if (cellErrors.length) { + return cellErrors; + } + + // Check if the alias already exists in the survey + if (surveyAliases.has(String(params.cell))) { + cellErrors.push({ + error: `Critter alias already exists in the Survey`, + solution: `Update the alias to be unique` + }); + } + + // Check if the alias already exists in the CSV + if (!isAliasUnique) { + cellErrors.push({ + error: `Critter alias already exists in the CSV`, + solution: `Update the alias to be unique` + }); + } + + return cellErrors; + }; +}; + +/** + * Get the critter collection unit cell validator. + * + * Rules: + * 1. The header must be a valid collection category for the TSN + * 2. The cell value must be a valid collection unit for the collection category + * + * @param {NestedRecord} rowDictionary The row dictionary. + * @param {CSVConfigUtils} configUtils The CSV config utils. + * @returns {*} {CSVCellValidator} The validate cell callback + */ +export const getCritterCollectionUnitCellValidator = ( + rowDictionary: NestedRecord, + configUtils: CSVConfigUtils +): CSVCellValidator => { + return (params: CSVParams) => { + if (params.cell === undefined) { + return []; + } + + const rowTsn = Number(configUtils.getCellValue('ITIS_TSN', params.row)); // Row TSN + const collectionUnitCellValue = String(params.cell); // Cell value + const collectionCategory = params.header; // Current header ie: collection category + + const rowDictionaryTsn = rowDictionary.get(rowTsn); + + // Check if the row TSN has associated collection units + if (!rowDictionaryTsn) { + return [ + { + error: `Collection units not found for TSN: ${rowTsn}`, + solution: `Validate TSN is correct and has collection units` + } + ]; + } + + const rowDictionaryCategory = rowDictionary.get(rowTsn, collectionCategory); + + // Check if the dynamic header is a valid collection category for the TSN + if (!rowDictionaryCategory) { + return [ + { + error: `Invalid collection category header`, + solution: `Use valid collection unit category header`, + values: Object.keys(rowDictionaryTsn) + } + ]; + } + + const rowDictionaryUnit = rowDictionary.get(rowTsn, collectionCategory, collectionUnitCellValue); + + // Check if the cell value is a valid collection unit for the collection category + if (!rowDictionaryUnit) { + return [ + { + error: `Invalid collection unit cell value`, + solution: `Use valid collection unit cell value`, + values: Object.keys(rowDictionaryCategory) + } + ]; + } + + return []; + }; +}; + +/** + * Get the collection unit cell setter. + * + * @param {NestedRecord} rowDictionary The row dictionary. + * @param {CSVConfigUtils} configUtils The CSV config utils. + * @returns {*} {CSVCellSetter} The set cell value callback + */ +export const getCritterCollectionUnitCellSetter = ( + rowDictionary: NestedRecord, + configUtils: CSVConfigUtils +): CSVCellSetter => { + return (params: CSVParams) => { + if (params.cell === undefined) { + return undefined; + } + + const rowTsn = Number(configUtils.getCellValue('ITIS_TSN', params.row)); + const collectionCategory = params.header; + const collectionUnitCellValue = String(params.cell); + + return rowDictionary.get(rowTsn, collectionCategory, collectionUnitCellValue); + }; +}; + +/** + * Get the critter sex cell validator. + * + * Rules: + * 1. The TSN must have sex measurements available + * 2. The cell value must be a valid sex option for the TSN or undefined + * + * @param {NestedRecord} rowDictionary The row dictionary. + * @param {CSVConfigUtils} configUtils The CSV config utils. + * @returns {*} {CSVCellValidator} The validate cell callback + */ +export const getCritterSexCellValidator = ( + rowDictionary: NestedRecord, + configUtils: CSVConfigUtils +): CSVCellValidator => { + return (params: CSVParams) => { + if (params.cell === undefined) { + return []; + } + + const rowTsn = Number(configUtils.getCellValue('ITIS_TSN', params.row)); // Row TSN + const sexCellValue = String(params.cell); // Cell value + + const rowDictionaryTsn = rowDictionary.get(rowTsn); + + // Check if the row TSN has sex measurements available + if (!rowDictionaryTsn) { + return [ + { + error: `Sex is not a supported attribute for TSN: ${rowTsn}`, + solution: `Use a valid TSN that supports sex, or contact a system administrator to add additional sex values.` + } + ]; + } + + const rowDictionarySex = rowDictionary.get(rowTsn, sexCellValue); + + // Check if the cell value is a valid sex measurement for the TSN + if (!rowDictionarySex) { + return [ + { + error: `Sex cell value is invalid`, + solution: `Use valid sex option`, + values: Object.keys(rowDictionaryTsn) + } + ]; + } + + return []; + }; +}; + +/** + * Get the critter sex cell setter. + * + * @param {NestedRecord} rowDictionary The row dictionary. + * @param {CSVConfigUtils} configUtils The CSV config utils. + * @returns {*} {CSVCellValidator} The validate cell callback + */ +export const getCritterSexCellSetter = ( + rowDictionary: NestedRecord, + configUtils: CSVConfigUtils +): CSVCellSetter => { + return (params: CSVParams) => { + if (params.cell === undefined) { + return undefined; + } + + const rowTsn = Number(configUtils.getCellValue('ITIS_TSN', params.row)); + const sexCellValue = String(params.cell); + + return rowDictionary.get(rowTsn, sexCellValue); + }; +}; + +/** + * Get the Wildlife Health ID header cell validator. + * + * Rules: + * 1. The Wildlife Health ID must be in the format 'XX-XXXX' or undefined + * 2. The Wildlife Health ID must be unique in the CSV + * + * @param {CSVConfigUtils} configUtils The CSV config utils. + * @returns {*} {CSVCellValidator} The validate cell callback + */ +export const getWlhIDCellValidator = (configUtils: CSVConfigUtils): CSVCellValidator => { + return (params: CSVParams) => { + const cellErrors: CSVError[] = []; + + if (params.cell === undefined) { + return []; + } + + const isWlhIdUnique = configUtils.isCellUnique('WLH_ID', params.cell); + + if (!/^\d{2}-.+/.exec(String(params.cell))) { + cellErrors.push({ + error: `Invalid Wildlife Health ID format`, + solution: `Update the Wildlife Health ID to match the expected format 'XX-XXXX'` + }); + } + + if (!isWlhIdUnique) { + cellErrors.push({ + error: `Wildlife Health ID already exists in the CSV`, + solution: `Update the Wildlife Health ID to be unique` + }); + } + + return cellErrors; + }; +}; diff --git a/api/src/services/import-services/critter/import-critters-service.test.ts b/api/src/services/import-services/critter/import-critters-service.test.ts new file mode 100644 index 0000000000..3a4015ba93 --- /dev/null +++ b/api/src/services/import-services/critter/import-critters-service.test.ts @@ -0,0 +1,280 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import xlsx from 'xlsx'; +import { CSVConfigUtils } from '../../../utils/csv-utils/csv-config-utils'; +import * as headerConfig from '../../../utils/csv-utils/csv-header-configs'; +import { NestedRecord } from '../../../utils/nested-record'; +import { getMockDBConnection } from '../../../__mocks__/db'; +import { CritterbaseService } from '../../critterbase-service'; +import { SurveyCritterService } from '../../survey-critter-service'; +import * as critterConfig from './critter-header-configs'; +import { ImportCrittersService } from './import-critters-service'; + +chai.use(sinonChai); + +describe('ImportCrittersService', () => { + beforeEach(() => { + sinon.restore(); + }); + + describe('constructor', () => { + it('should create a new instance of the service', () => { + const mockConnection = getMockDBConnection(); + const worksheet = xlsx.utils.json_to_sheet([]); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + expect(service).to.be.instanceof(ImportCrittersService); + expect(service).to.have.property('connection', mockConnection); + expect(service).to.have.property('worksheet', worksheet); + expect(service).to.have.property('surveyId', 1); + + expect(service.configUtils).to.be.instanceof(CSVConfigUtils); + expect(service.surveyCritterService).to.be.instanceof(SurveyCritterService); + expect(service.critterbaseService).to.be.instanceof(CritterbaseService); + + expect(Object.keys(service._config.staticHeadersConfig)).to.deep.equal([ + 'ITIS_TSN', + 'ALIAS', + 'SEX', + 'WLH_ID', + 'DESCRIPTION' + ]); + }); + }); + + describe('_getCSVConfig', () => { + it('should return a valid CSVConfig object (no errors thrown)', async () => { + const mockConnection = getMockDBConnection(); + const worksheet = xlsx.utils.json_to_sheet([]); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + sinon.stub(service, '_getTsnHeaderConfig').resolves({ validateCell: () => [] }); + sinon.stub(service, '_getAliasHeaderConfig').resolves({ validateCell: () => [] }); + sinon.stub(service, '_getSexHeaderConfig').resolves({ validateCell: () => [], setCellValue: () => 'A' }); + sinon + .stub(service, '_getCollectionUnitDynamicHeaderConfig') + .resolves({ validateCell: () => [], setCellValue: () => 'B' }); + + sinon.stub(headerConfig, 'getDescriptionCellValidator').returns(() => []); + sinon.stub(critterConfig, 'getWlhIDCellValidator').returns(() => []); + + const config = await service.getCSVConfig(); + + expect(config.staticHeadersConfig.ITIS_TSN.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.ALIAS.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.SEX.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.SEX.setCellValue).to.be.a('function'); + expect(config.staticHeadersConfig.WLH_ID.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.DESCRIPTION.validateCell).to.be.a('function'); + expect(config.dynamicHeadersConfig?.validateCell).to.be.a('function'); + expect(config.dynamicHeadersConfig?.setCellValue).to.be.a('function'); + + expect(config.ignoreDynamicHeaders).to.be.false; + }); + + it('should return a valid CSVConfig object (when errors thrown)', async () => { + const mockConnection = getMockDBConnection(); + const worksheet = xlsx.utils.json_to_sheet([]); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + sinon.stub(service, '_getTsnHeaderConfig').resolves({ validateCell: () => [] }); + sinon.stub(service, '_getAliasHeaderConfig').resolves({ validateCell: () => [] }); + sinon.stub(service, '_getSexHeaderConfig').resolves({ validateCell: () => [], setCellValue: () => 'A' }); + sinon.stub(service, '_getCollectionUnitDynamicHeaderConfig').rejects(new Error('Dynamic header error')); + + sinon.stub(headerConfig, 'getDescriptionCellValidator').returns(() => []); + sinon.stub(critterConfig, 'getWlhIDCellValidator').returns(() => []); + + const config = await service.getCSVConfig(); + + expect(config.staticHeadersConfig.ITIS_TSN.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.ALIAS.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.SEX.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.SEX.setCellValue).to.be.a('function'); + expect(config.staticHeadersConfig.WLH_ID.validateCell).to.be.a('function'); + expect(config.staticHeadersConfig.DESCRIPTION.validateCell).to.be.a('function'); + + expect(config.dynamicHeadersConfig).to.be.undefined; + expect(config.ignoreDynamicHeaders).to.be.true; + }); + }); + + describe('_getTsnHeaderConfig', () => { + it('should return a valid header config object', async () => { + const mockConnection = getMockDBConnection(); + const worksheet = xlsx.utils.json_to_sheet([{ ITIS_TSN: '1234' }]); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + const getTaxonomyByTsnsStub = sinon + .stub(service.platformService, 'getTaxonomyByTsns') + .resolves([{ tsn: 1234, scientificName: 'test' }]); + const getTsnCellValidatorStub = sinon.stub(headerConfig, 'getTsnCellValidator').returns(() => []); + + const tsnHeaderConfig = await service._getTsnHeaderConfig(); + + expect(getTaxonomyByTsnsStub).to.have.been.calledOnceWithExactly(['1234']); + expect(getTsnCellValidatorStub).to.have.been.calledOnceWithExactly(new Set([1234])); + + expect(tsnHeaderConfig.validateCell).to.be.a('function'); + expect(tsnHeaderConfig.setCellValue).to.be.a('function'); + }); + }); + + describe('_getAliasHeaderConfig', () => { + it('should return a valid header config object', async () => { + const mockConnection = getMockDBConnection(); + const worksheet = xlsx.utils.json_to_sheet([{ ALIAS: 'test' }]); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + const getSurveyCritterAliasesStub = sinon + .stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases') + .resolves(new Set(['test'])); + const getCritterAliasCellValidatorStub = sinon + .stub(critterConfig, 'getCritterAliasCellValidator') + .returns(() => []); + + const aliasHeaderConfig = await service._getAliasHeaderConfig(); + + expect(getSurveyCritterAliasesStub).to.have.been.calledOnceWithExactly(1); + expect(getCritterAliasCellValidatorStub).to.have.been.calledOnceWithExactly( + new Set(['test']), + service.configUtils + ); + + expect(aliasHeaderConfig.validateCell).to.be.a('function'); + expect(aliasHeaderConfig.setCellValue).to.be.a('function'); + }); + }); + + describe('_getSexHeaderConfig', () => { + it('should return a valid header config object', async () => { + const mockConnection = getMockDBConnection(); + const worksheet = xlsx.utils.json_to_sheet([{ ITIS_TSN: 1234 }]); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + const getTaxonMeasurementsStub = sinon.stub(service.critterbaseService, 'getTaxonMeasurements').resolves({ + qualitative: [ + { + measurement_name: 'sex', + itis_tsn: 1234, + options: [ + { + option_label: 'male', + qualitative_option_id: 'maleUUID' + }, + { + option_label: 'female', + qualitative_option_id: 'femaleUUID' + } + ] + } + ] + } as any); + + const getSexCellValidatorStub = sinon.stub(critterConfig, 'getCritterSexCellValidator').returns(() => []); + const getSexCellSetterStub = sinon.stub(critterConfig, 'getCritterSexCellSetter').returns(() => 'A'); + + const sexHeaderConfig = await service._getSexHeaderConfig(); + + expect(getTaxonMeasurementsStub).to.have.been.calledWithExactly(1234); + expect(getSexCellValidatorStub).to.have.been.calledWithExactly( + new NestedRecord({ + 1234: { male: 'maleUUID', female: 'femaleUUID' } + }), + service.configUtils + ); + + expect(getSexCellSetterStub).to.have.been.calledWithExactly( + new NestedRecord({ + 1234: { male: 'maleUUID', female: 'femaleUUID' } + }), + service.configUtils + ); + + expect(sexHeaderConfig.validateCell).to.be.a('function'); + expect(sexHeaderConfig.setCellValue).to.be.a('function'); + }); + }); + + describe('_getCollectionUnitDynamicHeaderConfig', () => { + it('should return a valid header config object', async () => { + const mockConnection = getMockDBConnection(); + const worksheet = xlsx.utils.json_to_sheet([{ UNIT: 'unit', ITIS_TSN: 1234 }]); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + const findTaxonCollectionUnitsStub = sinon + .stub(service.critterbaseService, 'findTaxonCollectionUnits') + .resolves([{ category_name: 'category', unit_name: 'unit', collection_unit_id: 'uuid' }] as any[]); + + const getCollectionUnitCellValidatorStub = sinon + .stub(critterConfig, 'getCritterCollectionUnitCellValidator') + .returns(() => []); + + const getCollectionUnitCellSetterStub = sinon + .stub(critterConfig, 'getCritterCollectionUnitCellSetter') + .returns(() => 'value'); + + const config = await service._getCollectionUnitDynamicHeaderConfig(); + + expect(findTaxonCollectionUnitsStub).to.have.been.calledOnceWithExactly(1234); + + expect(getCollectionUnitCellValidatorStub).to.have.been.calledWithExactly( + new NestedRecord({ 1234: { category: { unit: 'uuid' } } }), + service.configUtils + ); + + expect(getCollectionUnitCellSetterStub).to.have.been.calledWithExactly( + new NestedRecord({ 1234: { category: { unit: 'uuid' } } }), + service.configUtils + ); + + expect(config.validateCell).to.be.a('function'); + expect(config.setCellValue).to.be.a('function'); + }); + }); + + describe('_getImportPayloads', () => { + it('should return all import payloads', () => { + const mockConnection = getMockDBConnection(); + const rows = [ + { + ITIS_TSN: '1234', + ALIAS: 'test', + SEX: 'male', + WLH_ID: '12-2222', + DESCRIPTION: 'comment', + POPULATION_UNIT: 'unit', + COLLECTION_UNIT: 'collection' + } + ]; + const worksheet = xlsx.utils.json_to_sheet(rows); + + const service = new ImportCrittersService(mockConnection, worksheet, 1); + + const payloads = service._getImportPayloads(rows); + + expect(payloads.simsPayload[0]).to.be.a('string'); + + expect(payloads.critterbasePayload.critters?.[0].itis_tsn).to.be.equal('1234'); + expect(payloads.critterbasePayload.critters?.[0].animal_id).to.be.equal('test'); + expect(payloads.critterbasePayload.critters?.[0].sex_qualitative_option_id).to.be.equal('male'); + expect(payloads.critterbasePayload.critters?.[0].wlh_id).to.be.equal('12-2222'); + expect(payloads.critterbasePayload.critters?.[0].critter_comment).to.be.equal('comment'); + expect(payloads.critterbasePayload.critters?.[0].critter_id).to.be.a('string'); + + expect(payloads.critterbasePayload.collections?.[0].critter_id).to.be.a('string'); + expect(payloads.critterbasePayload.collections?.[0].collection_unit_id).to.be.equal('unit'); + + expect(payloads.critterbasePayload.collections?.[1].critter_id).to.be.a('string'); + expect(payloads.critterbasePayload.collections?.[1].collection_unit_id).to.be.equal('collection'); + }); + }); +}); diff --git a/api/src/services/import-services/critter/import-critters-service.ts b/api/src/services/import-services/critter/import-critters-service.ts new file mode 100644 index 0000000000..c714f56530 --- /dev/null +++ b/api/src/services/import-services/critter/import-critters-service.ts @@ -0,0 +1,302 @@ +import { merge } from 'lodash'; +import { v4 } from 'uuid'; +import { WorkSheet } from 'xlsx'; +import { IDBConnection } from '../../../database/db'; +import { ApiGeneralError } from '../../../errors/api-error'; +import { CSVConfigUtils } from '../../../utils/csv-utils/csv-config-utils'; +import { validateCSVWorksheet } from '../../../utils/csv-utils/csv-config-validation'; +import { CSVConfig, CSVHeaderConfig, CSVRowValidated } from '../../../utils/csv-utils/csv-config-validation.interface'; +import { getDescriptionCellValidator, getTsnCellValidator } from '../../../utils/csv-utils/csv-header-configs'; +import { getLogger } from '../../../utils/logger'; +import { NestedRecord } from '../../../utils/nested-record'; +import { CritterbaseService, IBulkCreate } from '../../critterbase-service'; +import { DBService } from '../../db-service'; +import { PlatformService } from '../../platform-service'; +import { SurveyCritterService } from '../../survey-critter-service'; +import { + getCritterAliasCellValidator, + getCritterCollectionUnitCellSetter, + getCritterCollectionUnitCellValidator, + getCritterSexCellSetter, + getCritterSexCellValidator, + getWlhIDCellValidator +} from './critter-header-configs'; + +const defaultLog = getLogger('services/import/import-critters-service'); + +// Critter CSV static headers +export type CritterCSVStaticHeader = 'ITIS_TSN' | 'ALIAS' | 'SEX' | 'WLH_ID' | 'DESCRIPTION'; + +/** + * + * ImportCrittersService + * + * @class ImportCrittersService + * @extends DBService + * + */ +export class ImportCrittersService extends DBService { + _config: CSVConfig; + + surveyId: number; + worksheet: WorkSheet; + + configUtils: CSVConfigUtils; + + platformService: PlatformService; + critterbaseService: CritterbaseService; + surveyCritterService: SurveyCritterService; + + /** + * Instantiates an instance of ImportCrittersService + * + * @param {IDBConnection} connection - Database connection + * @param {number} surveyId - Survey identifier + */ + constructor(connection: IDBConnection, worksheet: WorkSheet, surveyId: number) { + super(connection); + + this._config = { + staticHeadersConfig: { + ITIS_TSN: { aliases: ['TAXON', 'SPECIES', 'TSN'] }, + ALIAS: { aliases: ['NICKNAME', 'NAME', 'ANIMAL_ID'] }, + SEX: { aliases: [], optional: true }, + WLH_ID: { aliases: ['WILDLIFE_HEALTH_ID', 'WILD LIFE HEALTH ID', 'WLHID'], optional: true }, + DESCRIPTION: { aliases: ['COMMENTS', 'COMMENT', 'NOTES'], optional: true } + }, + ignoreDynamicHeaders: false + }; + + this.surveyId = surveyId; + this.worksheet = worksheet; + + this.configUtils = new CSVConfigUtils(worksheet, this._config); + + this.platformService = new PlatformService(connection); + this.surveyCritterService = new SurveyCritterService(connection); + this.critterbaseService = new CritterbaseService({ + keycloak_guid: connection.systemUserGUID(), + username: connection.systemUserIdentifier() + }); + } + + /** + * Import a Critter CSV worksheet into Critterbase and SIMS. + * + * @async + * @throws {ApiGeneralError} - If unable to fully insert records into Critterbase + * @returns {*} {Promise} List of inserted survey critter ids + */ + async importCSVWorksheet(): Promise { + const config = await this.getCSVConfig(); + + const { errors, rows } = validateCSVWorksheet(this.worksheet, config); + + if (errors.length) { + throw new ApiGeneralError('Failed to validate CSV', errors); + } + + const payloads = this._getImportPayloads(rows); + + // Add critters to Critterbase + const bulkResponse = await this.critterbaseService.bulkCreate(payloads.critterbasePayload); + + // Check critterbase inserted the full list of critters + // In reality this error should not be triggered, safeguard to prevent floating critter ids in SIMS + if (bulkResponse.created.critters !== payloads.simsPayload.length) { + throw new ApiGeneralError('Unable to fully import critters from CSV', [ + 'importCrittersService->importCSVWorksheet', + 'critterbase bulk create response count !== critterIds.length' + ]); + } + + // Add Critters to SIMS survey + await this.surveyCritterService.addCrittersToSurvey(this.surveyId, payloads.simsPayload); + } + + /** + * Get the Critter CSV config - this will fetch all the header configs and merge them into the final config. + * + * Note: This will simulate a multi-step validation process if the TSNs are invalid. This is because the TSNs are + * dependencies for the other header configs, so all TSN related errors must be resolved first. + * + * @returns {*} {Promise>} The Critter CSV config + */ + async getCSVConfig(): Promise> { + const [tsnHeaderConfig, aliasHeaderConfig, sexHeaderConfig, dynamicHeadersConfig] = await Promise.all([ + this._getTsnHeaderConfig(), + this._getAliasHeaderConfig(), + this._getSexHeaderConfig().catch(() => undefined), // If this throws due to invalid TSNs, we can ignore this header till TSNs are fixed + this._getCollectionUnitDynamicHeaderConfig().catch(() => undefined) // Same for the dynamic columns + ]); + + const newConfig = merge(this._config, { + staticHeadersConfig: { + ITIS_TSN: tsnHeaderConfig, + ALIAS: aliasHeaderConfig, + SEX: sexHeaderConfig, + WLH_ID: { validateCell: getWlhIDCellValidator(this.configUtils) }, + DESCRIPTION: { validateCell: getDescriptionCellValidator() } + }, + dynamicHeadersConfig: dynamicHeadersConfig, + ignoreDynamicHeaders: !dynamicHeadersConfig + }); + + return newConfig; + } + + /** + * Get the Critterbase and SIMS import payloads. + * + * @param {CSVRowValidated[]} rows - The validated CSV rows + * @returns {*} { simsPayload: string[]; critterbasePayload: IBulkCreate } The import payloads + */ + _getImportPayloads(rows: CSVRowValidated[]): { + simsPayload: string[]; + critterbasePayload: IBulkCreate; + } { + const simsPayload: string[] = []; + const critterbasePayload: IBulkCreate = { critters: [], collections: [] }; + + // Convert rows to Critterbase and SIMS payloads + for (const row of rows) { + const critterId = v4(); + + // SIMS payload + simsPayload.push(critterId); + + // Critterbase static headers payload + critterbasePayload.critters?.push({ + critter_id: critterId, + sex_qualitative_option_id: row.SEX, + itis_tsn: row.ITIS_TSN, + animal_id: row.ALIAS, + wlh_id: row.WLH_ID, + critter_comment: row.DESCRIPTION + }); + + // Critterbase dynamic headers payload + this.configUtils.worksheetDynamicHeaders.forEach((header) => { + if (row[header]) { + critterbasePayload.collections?.push({ + collection_unit_id: row[header], + critter_id: critterId + }); + } + }); + } + + defaultLog.debug({ label: 'critter import payloads', simsPayload, critterbasePayload }); + + return { simsPayload, critterbasePayload }; + } + + /** + * Get the TSN header config. + * + * Validation rules: + * 1. TSN must be a number + * 2. TSN must be a real ITIS TSN + * + * @returns {*} {Promise} The TSN header config + */ + async _getTsnHeaderConfig(): Promise { + const rowTsns = this.configUtils.getUniqueCellValues('ITIS_TSN'); + const taxonomy = await this.platformService.getTaxonomyByTsns(rowTsns); + const allowedTsns = new Set(taxonomy.map((taxon) => taxon.tsn)); + + return { + validateCell: getTsnCellValidator(allowedTsns), + setCellValue: (params) => Number(params.cell) + }; + } + + /** + * Get the CSV Alias header config. + * + * Validation rules: + * 1. Alias must be a string + * 2. Alias must be unique in the SIMS Survey + * 3. Alias must be unique in the CSV + * + * @returns {*} {Promise} The alias header config + */ + async _getAliasHeaderConfig(): Promise { + const surveyAliases = await this.surveyCritterService.getUniqueSurveyCritterAliases(this.surveyId); + + return { + validateCell: getCritterAliasCellValidator(surveyAliases, this.configUtils), + setCellValue: (params) => String(params.cell) + }; + } + + /** + * Get the CSV Sex header config. + * + * Validation rules: + * 1. Sex must be a string + * 2. Sex must be a valid option in Critterbase for the TSN + * + * @returns {*} {CSVHeaderConfig} The sex header config + */ + async _getSexHeaderConfig(): Promise { + const rowDictionary = new NestedRecord(); + + const rowTsns = this.configUtils.getUniqueCellValues('ITIS_TSN'); + const measurements = await Promise.all(rowTsns.map((tsn) => this.critterbaseService.getTaxonMeasurements(tsn))); + + measurements.forEach((measurement, index) => { + const sexMeasurement = measurement.qualitative.find( + (measurement) => measurement.measurement_name.toLowerCase() === 'sex' + ); + + if (sexMeasurement) { + sexMeasurement.options.forEach((option) => { + const tsn = Number(rowTsns[index]); + const sexLabel = option.option_label; + + rowDictionary.set({ path: [tsn, sexLabel], value: option.qualitative_option_id }); + }); + } + }); + + return { + validateCell: getCritterSexCellValidator(rowDictionary, this.configUtils), + setCellValue: getCritterSexCellSetter(rowDictionary, this.configUtils) + }; + } + + /** + * Get the CSV Collection Unit dynamic header config. + * + * @returns {*} {Promise} The Collection Unit dynamic header config + */ + async _getCollectionUnitDynamicHeaderConfig(): Promise { + const rowDictionary = new NestedRecord(); + const rowTsns = this.configUtils.getUniqueCellValues('ITIS_TSN'); + // Get the collection units for all the tsns in the worksheet + const collectionUnits = await Promise.all( + rowTsns.map((tsn) => this.critterbaseService.findTaxonCollectionUnits(tsn)) + ); + + collectionUnits.forEach((collectionUnits, index) => { + collectionUnits.forEach((unit) => { + const category = unit.category_name; + const tsn = Number(rowTsns[index]); + const unitName = unit.unit_name; + + rowDictionary.set({ + path: [tsn, category, unitName], + value: unit.collection_unit_id + }); + + rowDictionary.set({ path: [tsn, category, unitName], value: unit.collection_unit_id }); + }); + }); + + return { + validateCell: getCritterCollectionUnitCellValidator(rowDictionary, this.configUtils), + setCellValue: getCritterCollectionUnitCellSetter(rowDictionary, this.configUtils) + }; + } +} diff --git a/api/src/services/import-services/critter/import-critters-strategy.interface.ts b/api/src/services/import-services/critter/import-critters-strategy.interface.ts deleted file mode 100644 index 31ad5300e2..0000000000 --- a/api/src/services/import-services/critter/import-critters-strategy.interface.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * A validated CSV Critter object - * - */ -export type CsvCritter = { - critter_id: string; - sex?: string; - itis_tsn: number; - animal_id: string; - wlh_id?: string; - critter_comment?: string; -} & { - [collectionUnitColumn: string]: unknown; -}; - -/** - * Invalidated CSV Critter object - * - */ -export type PartialCsvCritter = Partial & { critter_id: string }; diff --git a/api/src/services/import-services/critter/import-critters-strategy.test.ts b/api/src/services/import-services/critter/import-critters-strategy.test.ts deleted file mode 100644 index b8dd5af7fd..0000000000 --- a/api/src/services/import-services/critter/import-critters-strategy.test.ts +++ /dev/null @@ -1,621 +0,0 @@ -import chai, { expect } from 'chai'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { WorkSheet } from 'xlsx'; -import { getMockDBConnection } from '../../../__mocks__/db'; -import { CBQualitativeOption, IBulkCreateResponse } from '../../critterbase-service'; -import { ImportCrittersStrategy } from './import-critters-strategy'; -import { CsvCritter } from './import-critters-strategy.interface'; - -chai.use(sinonChai); - -const mockConnection = getMockDBConnection(); - -describe('ImportCrittersStrategy', () => { - describe('_getRowsToValidate', () => { - it('it should correctly format rows', () => { - const rows = [ - { - SEX: 'Male', - ITIS_TSN: 1, - WLH_ID: '10-1000', - ALIAS: 'Carl', - COMMENT: 'Test', - COLLECTION: 'Unit', - BAD_COLLECTION: 'Bad' - } - ]; - const service = new ImportCrittersStrategy(mockConnection, 1); - - const parsedRow = service._getRowsToValidate(rows, ['COLLECTION'])[0]; - - expect(parsedRow.sex).to.be.eq('Male'); - expect(parsedRow.itis_tsn).to.be.eq(1); - expect(parsedRow.wlh_id).to.be.eq('10-1000'); - expect(parsedRow.animal_id).to.be.eq('Carl'); - expect(parsedRow.critter_comment).to.be.eq('Test'); - expect(parsedRow.COLLECTION).to.be.eq('Unit'); - expect(parsedRow.TEST).to.be.undefined; - expect(parsedRow.BAD_COLLECTION).to.be.undefined; - }); - }); - - describe('_getCritterFromRow', () => { - it('should get all critter properties', () => { - const row: CsvCritter = { - critter_id: 'id', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - extra_property: 'test' - }; - const service = new ImportCrittersStrategy(mockConnection, 1); - - const critter = service._getCritterFromRow(row); - - expect(critter).to.be.eql({ - critter_id: 'id', - sex_qualitative_option_id: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment' - }); - }); - }); - - describe('_getCollectionUnitsFromRow', () => { - it('should get all collection unit properties', () => { - const row: CsvCritter = { - critter_id: 'id', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'ID1', - HERD: 'ID2' - }; - const service = new ImportCrittersStrategy(mockConnection, 1); - - const collectionUnits = service._getCollectionUnitsFromRow(row); - - expect(collectionUnits).to.be.deep.equal([ - { collection_unit_id: 'ID1', critter_id: 'id' }, - { collection_unit_id: 'ID2', critter_id: 'id' } - ]); - }); - }); - - describe('_getValidTsns', () => { - afterEach(() => { - sinon.restore(); - }); - - it('should return unique list of tsns', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const getTaxonomyStub = sinon.stub(service.platformService, 'getTaxonomyByTsns').resolves([ - { tsn: 1, scientificName: 'a' }, - { tsn: 2, scientificName: 'b' } - ]); - - const tsns = await service._getValidTsns([ - { critter_id: 'a', itis_tsn: 1 }, - { critter_id: 'b', itis_tsn: 2 } - ]); - - expect(getTaxonomyStub).to.have.been.calledWith(['1', '2']); - expect(tsns).to.deep.equal(['1', '2']); - }); - }); - - describe('_getCollectionUnitMap', () => { - afterEach(() => { - sinon.restore(); - }); - - const collectionUnitsA = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'COLLECTION', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'COLLECTION', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - const collectionUnitsB = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'HERD', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'HERD', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - it('should return collection unit mapping', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const mockWorksheet = {} as unknown as WorkSheet; - - const findCollectionUnitsStub = sinon.stub(service.critterbaseService, 'findTaxonCollectionUnits'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - findCollectionUnitsStub.onCall(0).resolves(collectionUnitsA); - findCollectionUnitsStub.onCall(1).resolves(collectionUnitsB); - - const mapping = await service._getCollectionUnitMap(mockWorksheet, ['1', '2']); - expect(getColumnsStub).to.have.been.calledWith(mockWorksheet); - expect(findCollectionUnitsStub).to.have.been.calledTwice; - - expect(mapping).to.be.instanceof(Map); - expect(mapping.get('COLLECTION')).to.be.deep.equal({ collectionUnits: collectionUnitsA, tsn: 1 }); - expect(mapping.get('HERD')).to.be.deep.equal({ collectionUnits: collectionUnitsB, tsn: 2 }); - }); - - it('should return empty map when no collection unit columns', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const mockWorksheet = {} as unknown as WorkSheet; - - const findCollectionUnitsStub = sinon.stub(service.critterbaseService, 'findTaxonCollectionUnits'); - getColumnsStub.returns([]); - - const mapping = await service._getCollectionUnitMap(mockWorksheet, ['1', '2']); - expect(getColumnsStub).to.have.been.calledWith(mockWorksheet); - expect(findCollectionUnitsStub).to.have.not.been.called; - - expect(mapping).to.be.instanceof(Map); - }); - }); - - describe('insert', () => { - afterEach(() => { - sinon.restore(); - }); - - const critters: CsvCritter[] = [ - { - critter_id: '1', - sex: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - COLLECTION: 'Collection Unit' - }, - { - critter_id: '2', - sex: 'Female', - itis_tsn: 2, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment', - HERD: 'Herd Unit' - } - ]; - - it('should correctly parse collection units and critters and insert into sims / critterbase', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const critterbaseBulkCreateStub = sinon.stub(service.critterbaseService, 'bulkCreate'); - const simsAddSurveyCrittersStub = sinon.stub(service.surveyCritterService, 'addCrittersToSurvey'); - - critterbaseBulkCreateStub.resolves({ created: { critters: 2, collections: 1 } } as IBulkCreateResponse); - simsAddSurveyCrittersStub.resolves([1]); - - const ids = await service.insert(critters); - - expect(critterbaseBulkCreateStub).to.have.been.calledWithExactly({ - critters: [ - { - critter_id: '1', - sex_qualitative_option_id: 'Male', - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment' - }, - { - critter_id: '2', - sex_qualitative_option_id: 'Female', - itis_tsn: 2, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'comment' - } - ], - collections: [ - { collection_unit_id: 'Collection Unit', critter_id: '1' }, - { collection_unit_id: 'Herd Unit', critter_id: '2' } - ] - }); - - expect(ids).to.be.deep.equal([1]); - }); - - it('should throw error if response from critterbase is less than provided critters', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const critterbaseBulkCreateStub = sinon.stub(service.critterbaseService, 'bulkCreate'); - const simsAddSurveyCrittersStub = sinon.stub(service.surveyCritterService, 'addCrittersToSurvey'); - - critterbaseBulkCreateStub.resolves({ created: { critters: 1, collections: 1 } } as IBulkCreateResponse); - simsAddSurveyCrittersStub.resolves([1]); - - try { - await service.insert(critters); - expect.fail(); - } catch (err: any) { - expect(err.message).to.be.equal('Unable to fully import critters from CSV'); - } - - expect(simsAddSurveyCrittersStub).to.not.have.been.called; - }); - }); - - describe('validateRows', () => { - afterEach(() => { - sinon.restore(); - }); - - const collectionUnitsA = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'COLLECTION', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'COLLECTION', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - const collectionUnitsB = [ - { - collection_unit_id: '1', - collection_category_id: '2', - category_name: 'HERD', - unit_name: 'UNIT_A', - description: 'description' - }, - { - collection_unit_id: '2', - collection_category_id: '3', - category_name: 'HERD', - unit_name: 'UNIT_B', - description: 'description' - } - ]; - - const sexOptionsA: CBQualitativeOption[] = [ - { qualitative_option_id: 'A1', option_label: 'Male', option_value: 0, option_desc: 'description' }, - { qualitative_option_id: 'A2', option_label: 'Female', option_value: 1, option_desc: 'description' } - ]; - const sexOptionsB: CBQualitativeOption[] = [ - { qualitative_option_id: 'B1', option_label: 'Female', option_value: 0, option_desc: 'description' }, - { qualitative_option_id: 'B2', option_label: 'Hermaphroditic', option_value: 1, option_desc: 'description' } - ]; - - it('should return successful', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - const sexMapStub = sinon.stub(service, '_getSpeciesSexMap'); - - getColumnsStub.returns(['COLLECTION', 'HERD']); - surveyAliasesStub.resolves(new Set(['Not Carl', 'Carlita'])); - getValidTsnsStub.resolves(['1', '2']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - sexMapStub.resolves( - new Map([ - [1, { sexes: sexOptionsA }], - [2, { sexes: sexOptionsB }] - ]) - ); - - const rows = [ - { - ITIS_TSN: 1, - SEX: sexOptionsA[0].option_label, - ALIAS: 'Carl', - WLH_ID: '10-1000', - DESCRIPTION: 'A', - COLLECTION: 'UNIT_A' - }, - { - ITIS_TSN: 2, - SEX: sexOptionsB[1].option_label, - ALIAS: 'Carl2', - WLH_ID: '10-1000', - DESCRIPTION: 'B', - HERD: 'UNIT_B' - } - ]; - - const validation = await service.validateRows(rows, {}); - - if (validation.success) { - // The sex property is renamed to sex_qualitative_option_id in _getCrittersFromRow, after validateRows() - expect(validation.data[0]).to.contain({ - sex: sexOptionsA[0].qualitative_option_id, - itis_tsn: 1, - animal_id: 'Carl', - wlh_id: '10-1000', - critter_comment: 'A', - COLLECTION: '1' - }); - - expect(validation.data[1]).to.contain({ - sex: sexOptionsB[1].qualitative_option_id, - itis_tsn: 2, - animal_id: 'Carl2', - wlh_id: '10-1000', - critter_comment: 'B', - HERD: '2' - }); - } else { - expect.fail(); - } - }); - - it('should allow optional columns to be excluded from the csv', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const sexMapStub = sinon.stub(service, '_getSpeciesSexMap'); - - surveyAliasesStub.resolves(new Set([])); - getValidTsnsStub.resolves(['1']); - sexMapStub.resolves(new Map([[1, { sexes: [] }]])); - - const rows = [ - { - ITIS_TSN: 1, - ALIAS: 'Carl1' - }, - { - ITIS_TSN: 1, - ALIAS: 'Carl2', - DESCRIPTION: 'A' - } - ]; - - const validation = await service.validateRows(rows, {}); - - if (validation.success) { - expect(validation.data[0]).to.contain({ - itis_tsn: 1, - animal_id: 'Carl1' - }); - expect(validation.data[1]).to.contain({ - itis_tsn: 1, - animal_id: 'Carl2', - critter_comment: 'A' - }); - } else { - expect.fail(); - } - }); - - it('should return error when wlh_id invalid regex', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const sexMapStub = sinon.stub(service, '_getSpeciesSexMap'); - - surveyAliasesStub.resolves(new Set([])); - getValidTsnsStub.resolves(['1']); - sexMapStub.resolves(new Map([[1, { sexes: [] }]])); - - const rows = [ - { - ITIS_TSN: 1, - ALIAS: 'Carl', - WLH_ID: '1-1000', - DESCRIPTION: 'A' - }, - { - ITIS_TSN: 1, - ALIAS: 'Carl2', - WLH_ID: '101000', - DESCRIPTION: 'A' - } - ]; - - const validation = await service.validateRows(rows, {}); - - if (validation.success) { - expect.fail(); - } else { - const errorMessages = validation.error.issues.map((issue) => issue.message); - // Define a regex pattern to match the general structure of the error message - const errorPattern = /incorrectly formatted\./; - // Check that all error messages contain the expected pattern. The full error message is dynamic. - expect(errorMessages).to.satisfy((messages: string[]) => messages.every((msg) => errorPattern.test(msg))); - } - }); - - it('should return error when itis_tsn invalid option or undefined', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const sexMapStub = sinon.stub(service, '_getSpeciesSexMap'); - - surveyAliasesStub.resolves(new Set([])); - getValidTsnsStub.resolves(['1']); - sexMapStub.resolves(new Map([[1, { sexes: [] }]])); - - const rows = [ - { - ITIS_TSN: undefined, - SEX: null, - ALIAS: 'Carl', - WLH_ID: '10-1000', - DESCRIPTION: 'A' - }, - { - ITIS_TSN: 3, - SEX: null, - ALIAS: 'Carl2', - WLH_ID: '10-1000', - DESCRIPTION: 'A' - } - ]; - - const validation = await service.validateRows(rows, {}); - - if (validation.success) { - expect.fail(); - } else { - const errorMessages = validation.error.issues.map((issue) => issue.message); - // Define a regex pattern to match the general structure of the error message - const errorPattern = /does not exist\./; - // Check that all error messages contain the expected pattern. The full error message is dynamic. - expect(errorMessages).to.satisfy((messages: string[]) => messages.every((msg) => errorPattern.test(msg))); - } - }); - - it('should return error if alias undefined, duplicate or exists in survey', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const sexMapStub = sinon.stub(service, '_getSpeciesSexMap'); - - surveyAliasesStub.resolves(new Set(['Carl3'])); - getValidTsnsStub.resolves(['1']); - sexMapStub.resolves(new Map([[1, { sexes: [] }]])); - - const rows = [ - { - ITIS_TSN: 1, - SEX: null, - ALIAS: undefined, - WLH_ID: '10-1000', - DESCRIPTION: 'A' - }, - { - ITIS_TSN: 1, - SEX: null, - ALIAS: 'Carl2', - WLH_ID: '10-1000', - DESCRIPTION: 'A' - }, - { - ITIS_TSN: 1, - SEX: null, - ALIAS: 'Carl2', - WLH_ID: '10-1000', - DESCRIPTION: 'A' - }, - { - ITIS_TSN: 1, - SEX: null, - ALIAS: 'Carl3', - WLH_ID: '10-1000', - DESCRIPTION: 'A' - } - ]; - - const validation = await service.validateRows(rows, {}); - - if (validation.success) { - expect.fail(); - } else { - const errorMessages = validation.error.issues.map((issue) => issue.message); - // Define a regex pattern to match the general structure of the error message - const errorPattern = /already exists in the Survey\./; - // Check that all error messages contain the expected pattern. The full error message is dynamic. - expect(errorMessages).to.satisfy((messages: string[]) => messages.every((msg) => errorPattern.test(msg))); - } - }); - - it('should return error if collection unit invalid value', async () => { - const service = new ImportCrittersStrategy(mockConnection, 1); - - const surveyAliasesStub = sinon.stub(service.surveyCritterService, 'getUniqueSurveyCritterAliases'); - const getValidTsnsStub = sinon.stub(service, '_getValidTsns'); - const collectionMapStub = sinon.stub(service, '_getCollectionUnitMap'); - const getColumnsStub = sinon.stub(service, '_getNonStandardColumns'); - const sexMapStub = sinon.stub(service, '_getSpeciesSexMap'); - - surveyAliasesStub.resolves(new Set([])); - getValidTsnsStub.resolves(['1', '2']); - getColumnsStub.returns(['COLLECTION', 'HERD']); - collectionMapStub.resolves( - new Map([ - ['COLLECTION', { collectionUnits: collectionUnitsA, tsn: 1 }], - ['HERD', { collectionUnits: collectionUnitsB, tsn: 2 }] - ]) - ); - sexMapStub.resolves(new Map([[1, { sexes: [] }]])); - - const rows = [ - { - ITIS_TSN: 1, - SEX: null, - ALIAS: 'Carl', - WLH_ID: '10-1000', - DESCRIPTION: 'A', - COLLECTION: 'UNIT_C' - }, - { - ITIS_TSN: 2, - SEX: null, - ALIAS: 'Carl2', - WLH_ID: '10-1000', - DESCRIPTION: 'A', - COLLECTION: 'UNIT_A' - } - ]; - - const validation = await service.validateRows(rows, {}); - - if (validation.success) { - expect.fail(); - } else { - expect(validation.error.issues).to.deep.equal([ - { row: 0, message: `Invalid COLLECTION. Cell value is not valid.` }, - { row: 1, message: `Invalid COLLECTION. Cell value not allowed for TSN.` } - ]); - } - }); - }); -}); diff --git a/api/src/services/import-services/critter/import-critters-strategy.ts b/api/src/services/import-services/critter/import-critters-strategy.ts deleted file mode 100644 index 08e2d04c2b..0000000000 --- a/api/src/services/import-services/critter/import-critters-strategy.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { keys, omit, toUpper, uniq } from 'lodash'; -import { v4 as uuid } from 'uuid'; -import { WorkSheet } from 'xlsx'; -import { IDBConnection } from '../../../database/db'; -import { ApiGeneralError } from '../../../errors/api-error'; -import { getLogger } from '../../../utils/logger'; -import { getTsnMeasurementTypeDefinitionMap } from '../../../utils/observation-xlsx-utils/measurement-column-utils'; -import { CSV_COLUMN_ALIASES } from '../../../utils/xlsx-utils/column-aliases'; -import { generateColumnCellGetterFromColumnValidator } from '../../../utils/xlsx-utils/column-validator-utils'; -import { getNonStandardColumnNamesFromWorksheet, IXLSXCSVValidator } from '../../../utils/xlsx-utils/worksheet-utils'; -import { - CBQualitativeOption, - CritterbaseService, - IBulkCreate, - ICollection, - ICollectionUnitWithCategory, - ICreateCritter -} from '../../critterbase-service'; -import { DBService } from '../../db-service'; -import { PlatformService } from '../../platform-service'; -import { SurveyCritterService } from '../../survey-critter-service'; -import { CSVImportStrategy, Row, Validation, ValidationError } from '../import-csv.interface'; -import { CsvCritter, PartialCsvCritter } from './import-critters-strategy.interface'; - -const defaultLog = getLogger('services/import/import-critters-service'); - -/** - * - * ImportCrittersStrategy - Injected into CSVImportStrategy as the CSV import dependency - * - * @example new CSVImportStrategy(new ImportCrittersStrategy(connection, surveyId)).import(file); - * - * @class ImportCrittersStrategy - * @extends DBService - * - */ -export class ImportCrittersStrategy extends DBService implements CSVImportStrategy { - platformService: PlatformService; - critterbaseService: CritterbaseService; - surveyCritterService: SurveyCritterService; - - surveyId: number; - - /** - * An XLSX validation config for the standard columns of a Critter CSV. - * - * Note: `satisfies` allows `keyof` to correctly infer key types, while also - * enforcing uppercase object keys. - */ - columnValidator = { - ITIS_TSN: { type: 'number', aliases: CSV_COLUMN_ALIASES.ITIS_TSN }, - SEX: { type: 'string', optional: true }, - ALIAS: { type: 'string', aliases: CSV_COLUMN_ALIASES.ALIAS }, - WLH_ID: { type: 'string', optional: true }, - DESCRIPTION: { type: 'string', aliases: CSV_COLUMN_ALIASES.DESCRIPTION, optional: true } - } satisfies IXLSXCSVValidator; - - /** - * Instantiates an instance of ImportCrittersStrategy - * - * @param {IDBConnection} connection - Database connection - * @param {number} surveyId - Survey identifier - */ - constructor(connection: IDBConnection, surveyId: number) { - super(connection); - - this.surveyId = surveyId; - - this.platformService = new PlatformService(connection); - this.surveyCritterService = new SurveyCritterService(connection); - this.critterbaseService = new CritterbaseService({ - keycloak_guid: connection.systemUserGUID(), - username: connection.systemUserIdentifier() - }); - } - - /** - * Get non-standard columns (collection unit columns) from worksheet. - * - * @param {WorkSheet} worksheet - Xlsx worksheet - * @returns {string[]} Array of non-standard headers from CSV (worksheet) - */ - _getNonStandardColumns(worksheet: WorkSheet) { - return uniq(getNonStandardColumnNamesFromWorksheet(worksheet, this.columnValidator)); - } - - /** - * Get critter from properties from row. - * - * @param {CsvCritter} row - Row object as CsvCritter - * @returns {ICreateCritter} Create critter object - */ - _getCritterFromRow(row: CsvCritter): ICreateCritter { - return { - critter_id: row.critter_id, - sex_qualitative_option_id: row.sex, - itis_tsn: row.itis_tsn, - animal_id: row.animal_id, - wlh_id: row.wlh_id, - critter_comment: row.critter_comment - }; - } - - /** - * Get list of collection units from row. - * - * @param {CsvCritter} row - Row object as a CsvCritter - * @returns {ICollection[]} Array of collection units - */ - _getCollectionUnitsFromRow(row: CsvCritter): ICollection[] { - const critterId = row.critter_id; - - // Get portion of row object that is not a critter - const partialRow: { [key: keyof ICreateCritter | keyof CsvCritter]: any } = omit(row, [ - ...keys(this._getCritterFromRow(row)), - 'sex' as keyof CsvCritter - ]); - - // Keys of collection units - const collectionUnitKeys = keys(partialRow); - - // Return an array of formatted collection units for bulk create - return collectionUnitKeys - .filter((key) => partialRow[key]) - .map((key) => ({ collection_unit_id: partialRow[key], critter_id: critterId })); - } - - /** - * Get a Set of valid ITIS TSNS from xlsx worksheet rows. - * - * @async - * @returns {Promise} Unique Set of valid TSNS from worksheet. - */ - async _getValidTsns(rows: PartialCsvCritter[]): Promise { - // Get a unique list of tsns from worksheet - const critterTsns = uniq(rows.map((row) => String(row.itis_tsn))); - - // Query the platform service (taxonomy) for matching tsns - const taxonomy = await this.platformService.getTaxonomyByTsns(critterTsns); - - return taxonomy.map((taxon) => String(taxon.tsn)); - } - - /** - * Get a mapping of collection units for a list of tsns. - * Used in the zod validation. - * - * @example new Map([['Population Unit', new Set(['Atlin', 'Unit B'])]]); - * - * @async - * @param {WorkSheet} worksheet - Xlsx Worksheet - * @param {string[]} tsns - List of unique and valid TSNS - * @returns {Promise} Collection unit mapping - */ - async _getCollectionUnitMap(worksheet: WorkSheet, tsns: string[]) { - const collectionUnitMap = new Map(); - - const collectionUnitColumns = this._getNonStandardColumns(worksheet); - - // If no collection unit columns return empty Map - if (!collectionUnitColumns.length) { - return collectionUnitMap; - } - - // Get the collection units for all the tsns in the worksheet - const tsnCollectionUnits = await Promise.all( - tsns.map((tsn) => this.critterbaseService.findTaxonCollectionUnits(tsn)) - ); - - tsnCollectionUnits.forEach((collectionUnits, index) => { - if (collectionUnits.length) { - // TODO: Is this correct? - collectionUnitMap.set(toUpper(collectionUnits[0].category_name), { collectionUnits, tsn: Number(tsns[index]) }); - } - }); - - return collectionUnitMap; - } - - /** - * Get a mapping of sex values for a list of tsns. - * Used in the zod validation. - * - * @example new Map([['180844', new Set(['Male', 'Female'])]]); - * - * @async - * @param {string[]} tsns - List of unique and valid TSNS - * @returns {Promise} Sex mapping - */ - async _getSpeciesSexMap(tsns: string[]): Promise> { - // Initialize the sex map - const sexMap = new Map(); - - // Fetch the measurement type definitions - const tsnMeasurementTypeDefinitionMap = await getTsnMeasurementTypeDefinitionMap(tsns, this.critterbaseService); - - // Iterate over each TSN to populate the sexMap - tsns.forEach((tsn) => { - // Get the sex options for the current species - const measurements = tsnMeasurementTypeDefinitionMap[tsn]; - - // Look for a measurement called "sex" (case insensitive) - const sexMeasurement = measurements.qualitative.find((qual) => qual.measurement_name.toLowerCase() === 'sex'); - - // If there is a measurement called sex, add the options to the sexMap - sexMap.set(Number(tsn), { - sexes: sexMeasurement?.options ?? [] - }); - }); - - return sexMap; - } - - /** - * Parse the CSV rows into the Critterbase critter format. - * - * @param {Row[]} rows - CSV rows - * @param {string[]} collectionUnitColumns - Non standard columns - * @returns {PartialCsvCritter[]} CSV critters before validation - */ - _getRowsToValidate(rows: Row[], collectionUnitColumns: string[]): PartialCsvCritter[] { - const getColumnCell = generateColumnCellGetterFromColumnValidator(this.columnValidator); - - return rows.map((row) => { - // Standard critter properties from CSV - const standardCritterRow = { - critter_id: uuid(), // Generate a uuid for each critter for convienence - sex: getColumnCell(row, 'SEX').cell, - itis_tsn: getColumnCell(row, 'ITIS_TSN').cell, - wlh_id: getColumnCell(row, 'WLH_ID').cell, - animal_id: getColumnCell(row, 'ALIAS').cell, - critter_comment: getColumnCell(row, 'DESCRIPTION').cell - }; - - // All other properties must be collection units ie: `population unit` or `herd unit` etc... - collectionUnitColumns.forEach((categoryHeader) => { - standardCritterRow[categoryHeader] = row[categoryHeader]; - }); - - return standardCritterRow; - }); - } - - /** - * Validate CSV worksheet rows against reference data. - * - * @async - * @param {Row[]} rows - Invalidated CSV rows - * @param {WorkSheet} worksheet - Xlsx worksheet - * @returns {Promise>} Conditional validation object - */ - async validateRows(rows: Row[], worksheet: WorkSheet): Promise> { - const nonStandardColumns = this._getNonStandardColumns(worksheet); - const rowsToValidate = this._getRowsToValidate(rows, nonStandardColumns); - - // Retrieve the dynamic validation config - const [validRowTsns, surveyCritterAliases] = await Promise.all([ - this._getValidTsns(rowsToValidate), - this.surveyCritterService.getUniqueSurveyCritterAliases(this.surveyId) - ]); - const collectionUnitMap = await this._getCollectionUnitMap(worksheet, validRowTsns); - - // Get sex options for each species being imported - const sexMap = await this._getSpeciesSexMap(validRowTsns); - - // Parse reference data for validation - const tsnSet = new Set(validRowTsns.map((tsn) => Number(tsn))); - const csvCritterAliases = rowsToValidate.map((row) => row.animal_id); - - // Track the row validation errors - const errors: ValidationError[] = []; - - const csvCritters = rowsToValidate.map((row, index) => { - const tsn = row.itis_tsn; - - /** - * -------------------------------------------------------------------- - * STANDARD ROW VALIDATION - * -------------------------------------------------------------------- - */ - - // WLH_ID must follow regex pattern - const invalidWlhId = row.wlh_id && !/^\d{2}-.+/.exec(row.wlh_id); - // ITIS_TSN is required and be a valid TSN - const invalidTsn = !tsn || !tsnSet.has(tsn); - // ALIAS is required and must not already exist in Survey or CSV - const invalidAlias = - !row.animal_id || - surveyCritterAliases.has(row.animal_id) || - csvCritterAliases.filter((value) => value === row.animal_id).length > 1; - - if (invalidWlhId) { - errors.push({ - row: index, - message: `Wildlife health ID ${row.wlh_id} is incorrectly formatted. Expected a 2-digit hyphenated prefix like '18-98491'.` - }); - } - if (invalidTsn) { - errors.push({ row: index, message: `Species TSN ${tsn} does not exist.` }); - } - if (invalidAlias) { - errors.push({ - row: index, - message: `Animal ${row.animal_id} already exists in the Survey. Duplicate names are not allowed.` - }); - } - - /** - * -------------------------------------------------------------------- - * SEX VALIDATION - * -------------------------------------------------------------------- - */ - if (tsn) { - // Get the sex options from the sexMap - const sexOptionsForTsn = sexMap.get(tsn); - - // If no sex value is given, delete the sex column - if (!row.sex) { - delete row.sex; - } - - // If a sex value is given but sex is not allowed for the tsn, add an error message - if (!sexOptionsForTsn && row.sex) { - errors.push({ - row: index, - message: `Sex is not a supported attribute for TSN ${tsn}. Please contact a system administrator if it should be.` - }); - } - - // If sex is allowed and a value is given, look for a matching quantitative_option_id - if (sexOptionsForTsn && row.sex) { - const sexMatch = sexOptionsForTsn.sexes.find( - (sex) => sex.option_label.toLowerCase() === row.sex?.toLowerCase() - ); - - // If the given value is not valid, add an error message - if (!sexMatch) { - errors.push({ - row: index, - message: `${sexMatch} is not a valid sex option for TSN ${tsn}. Did you mean one of ${sexOptionsForTsn.sexes.join( - ',' - )}` - }); - } else { - // If the value is valid, update the cell with the qualitative_option_id - row.sex = sexMatch.qualitative_option_id; - } - } - } - - /** - * -------------------------------------------------------------------- - * NON-STANDARD ROW VALIDATION - * -------------------------------------------------------------------- - */ - - nonStandardColumns.forEach((column) => { - const collectionUnitColumn = collectionUnitMap.get(column); - // Remove property if undefined or not a collection unit - if (!collectionUnitColumn || !row[column]) { - delete row[column]; - return; - } - // Attempt to find the collection unit with the cell value from the mapping - const collectionUnitMatch = collectionUnitColumn.collectionUnits.find( - (unit) => unit.unit_name.toLowerCase() === String(row[column]).toLowerCase() - ); - // Collection unit must be a valid value - if (!collectionUnitMatch) { - errors.push({ row: index, message: `Invalid ${column}. Cell value is not valid.` }); - } - // Collection unit must have correct TSN mapping - else if (row.itis_tsn !== collectionUnitColumn.tsn) { - errors.push({ row: index, message: `Invalid ${column}. Cell value not allowed for TSN.` }); - } else { - // Update the cell to be the collection unit id - row[column] = collectionUnitMatch.collection_unit_id; - } - }); - - return row; - }); - - // If validation successful the rows should all be CsvCritters - if (!errors.length) { - return { success: true, data: csvCritters as CsvCritter[] }; - } - - return { success: false, error: { issues: errors } }; - } - - /** - * Insert CSV critters into Critterbase and SIMS. - * - * @async - * @param {CsvCritter[]} critterRows - CSV row critters - * @throws {ApiGeneralError} - If unable to fully insert records into Critterbase - * @returns {Promise} List of inserted survey critter ids - */ - async insert(critterRows: CsvCritter[]): Promise { - const simsPayload: string[] = []; - const critterbasePayload: IBulkCreate = { critters: [], collections: [] }; - - // Convert rows to Critterbase and SIMS payloads - for (const row of critterRows) { - simsPayload.push(row.critter_id); - critterbasePayload.critters?.push(this._getCritterFromRow(row)); - critterbasePayload.collections = critterbasePayload.collections?.concat(this._getCollectionUnitsFromRow(row)); - } - - defaultLog.debug({ label: 'critter import payloads', simsPayload, critterbasePayload }); - - // Add critters to Critterbase - const bulkResponse = await this.critterbaseService.bulkCreate(critterbasePayload); - - // Check critterbase inserted the full list of critters - // In reality this error should not be triggered, safeguard to prevent floating critter ids in SIMS - if (bulkResponse.created.critters !== simsPayload.length) { - throw new ApiGeneralError('Unable to fully import critters from CSV', [ - 'importCrittersStrategy -> insertCsvCrittersIntoSimsAndCritterbase', - 'critterbase bulk create response count !== critterIds.length' - ]); - } - - // Add Critters to SIMS survey - return this.surveyCritterService.addCrittersToSurvey(this.surveyId, simsPayload); - } -} diff --git a/api/src/services/import-services/marking/import-markings-strategy.test.ts b/api/src/services/import-services/marking/import-markings-strategy.test.ts index 4cfc6e192a..074cc472f2 100644 --- a/api/src/services/import-services/marking/import-markings-strategy.test.ts +++ b/api/src/services/import-services/marking/import-markings-strategy.test.ts @@ -24,7 +24,7 @@ describe('ImportMarkingsStrategy', () => { G1: { t: 's', v: 'PRIMARY_COLOUR' }, H1: { t: 's', v: 'SECONDARY_COLOUR' }, I1: { t: 's', v: 'DESCRIPTION' }, // testing alias works - A2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + A2: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, B2: { t: 's', v: 'Carl' }, C2: { t: 's', v: '10:10:12' }, D2: { t: 's', v: 'Left ear' }, // testing case insensitivity @@ -100,8 +100,8 @@ describe('ImportMarkingsStrategy', () => { try { const data = await importCSV(new MediaFile('test', 'test', 'test' as unknown as Buffer), strategy); expect(data).to.deep.equal(2); - } catch (err: any) { - expect.fail(); + } catch (error: any) { + expect.fail(error); } }); }); diff --git a/api/src/services/import-services/measurement/import-measurements-strategy.test.ts b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts index 9503e0569c..c60c76e989 100644 --- a/api/src/services/import-services/measurement/import-measurements-strategy.test.ts +++ b/api/src/services/import-services/measurement/import-measurements-strategy.test.ts @@ -26,12 +26,12 @@ describe('importMeasurementsStrategy', () => { E1: { t: 's', v: 'skull condition' }, F1: { t: 's', v: 'unknown' }, A2: { t: 's', v: 'carl' }, - B2: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B2: { t: 's', v: '2024-10-10' }, C2: { t: 's', v: '10:10:12' }, D2: { t: 'n', w: '2', v: 2 }, E2: { t: 'n', w: '0', v: 'good' }, A3: { t: 's', v: 'carlita' }, - B3: { z: 'm/d/yy', t: 'd', v: '2024-10-10T07:00:00.000Z', w: '10/10/24' }, + B3: { z: 'yyyy-mm-dd', t: 'd', v: new Date('2024-10-10T07:00:00.000Z'), w: '2024-10-10' }, C3: { t: 's', v: '10:10:12' }, D3: { t: 'n', w: '2', v: 2 }, E3: { t: 'n', w: '0', v: 'good' }, @@ -54,7 +54,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'carl', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:12' }] } as any ], [ @@ -63,7 +63,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'B', animal_id: 'carlita', itis_tsn: 'tsn2', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:12' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:12' }] } as any ] ]); @@ -143,8 +143,8 @@ describe('importMeasurementsStrategy', () => { } ] }); - } catch (e: any) { - expect.fail(); + } catch (error: any) { + expect.fail(error); } }); }); @@ -183,7 +183,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias', @@ -191,7 +191,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -205,7 +205,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias2', @@ -213,7 +213,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias2', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -227,7 +227,7 @@ describe('importMeasurementsStrategy', () => { const conn = getMockDBConnection(); const strategy = new ImportMeasurementsStrategy(conn, 1); - const row = { ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10' }; + const row = { ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10' }; const critterAliasMap = new Map([ [ 'alias', @@ -235,7 +235,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '11/11/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-11-11', capture_time: '10:10:10' }] } as any ] ]); @@ -344,7 +344,7 @@ describe('importMeasurementsStrategy', () => { critter_id: 'A', animal_id: 'alias', itis_tsn: 'tsn1', - captures: [{ capture_id: 'B', capture_date: '10/10/2024', capture_time: '10:10:10' }] + captures: [{ capture_id: 'B', capture_date: '2024-10-10', capture_time: '10:10:10' }] } as any ] ]); @@ -372,7 +372,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -408,7 +408,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -444,7 +444,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -480,7 +480,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: undefined, optionId: 'C' }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -514,7 +514,7 @@ describe('importMeasurementsStrategy', () => { ); validateQualitativeMeasurementCellStub.returns({ error: 'qualitative failed', optionId: undefined }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -548,7 +548,7 @@ describe('importMeasurementsStrategy', () => { ); validateQuantitativeMeasurementCellStub.returns({ error: 'quantitative failed', value: undefined }); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -573,7 +573,7 @@ describe('importMeasurementsStrategy', () => { getRowMetaStub.returns({ critter_id: 'A', tsn: 'tsn1', capture_id: 'C' }); getTsnMeasurementMapStub.resolves(new Map([['tsn1', { quantitative: [], qualitative: [] } as any]])); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); @@ -607,7 +607,7 @@ describe('importMeasurementsStrategy', () => { ]) ); - const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '10/10/2024', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; + const rows = [{ ALIAS: 'alias', CAPTURE_DATE: '2024-10-10', CAPTURE_TIME: '10:10:10', measurement: 'length' }]; const result = await strategy.validateRows(rows, {}); diff --git a/api/src/services/import-services/utils/datetime.test.ts b/api/src/services/import-services/utils/datetime.test.ts index 24e0452a13..71eb5ac1c6 100644 --- a/api/src/services/import-services/utils/datetime.test.ts +++ b/api/src/services/import-services/utils/datetime.test.ts @@ -25,14 +25,56 @@ describe('formatTimeString', () => { }); describe('areDatesEqual', () => { + const date1 = '2024-10-11'; + const date2 = '24-10-11'; + const date3 = '11-10-2024'; + const date4 = '11-10-24'; + + const date5 = '2024/10/11'; + const date6 = '11/10/2024'; + const date7 = '24/10/11'; + const date8 = '11/10/24'; + it('should be true when dates are equal in all formats', () => { - expect(areDatesEqual('10-10-2024', '10-10-2024')).to.be.true; - expect(areDatesEqual('10-10-2024', '10/10/2024')).to.be.true; - expect(areDatesEqual('10-10-2024', '10/10/24')).to.be.true; - expect(areDatesEqual('10-10-2024', '2024-10-10')).to.be.true; + expect(areDatesEqual(date1, date5)).to.be.true; + + expect(areDatesEqual(date3, date4)).to.be.true; + expect(areDatesEqual(date3, date6)).to.be.true; + expect(areDatesEqual(date3, date8)).to.be.true; + + expect(areDatesEqual(date4, date6)).to.be.true; + expect(areDatesEqual(date4, date8)).to.be.true; + + expect(areDatesEqual(date6, date8)).to.be.true; }); it('should fail if dates are incorrect format', () => { - expect(areDatesEqual('BAD DATE BAD', '10/10/2024')).to.be.false; + expect(areDatesEqual(date1, date2)).to.be.false; + expect(areDatesEqual(date1, date3)).to.be.false; + expect(areDatesEqual(date1, date4)).to.be.false; + expect(areDatesEqual(date1, date6)).to.be.false; + expect(areDatesEqual(date1, date7)).to.be.false; + expect(areDatesEqual(date1, date8)).to.be.false; + expect(areDatesEqual(date2, date3)).to.be.false; + + expect(areDatesEqual(date2, date4)).to.be.false; + expect(areDatesEqual(date2, date5)).to.be.false; + expect(areDatesEqual(date2, date6)).to.be.false; + expect(areDatesEqual(date2, date7)).to.be.false; + expect(areDatesEqual(date2, date8)).to.be.false; + + expect(areDatesEqual(date3, date5)).to.be.false; + expect(areDatesEqual(date3, date7)).to.be.false; + + expect(areDatesEqual(date4, date5)).to.be.false; + expect(areDatesEqual(date4, date7)).to.be.false; + + expect(areDatesEqual(date5, date6)).to.be.false; + expect(areDatesEqual(date5, date7)).to.be.false; + expect(areDatesEqual(date5, date8)).to.be.false; + + expect(areDatesEqual(date6, date7)).to.be.false; + + expect(areDatesEqual(date7, date8)).to.be.false; }); }); diff --git a/api/src/services/markdown-service.test.ts b/api/src/services/markdown-service.test.ts new file mode 100644 index 0000000000..468c91adcd --- /dev/null +++ b/api/src/services/markdown-service.test.ts @@ -0,0 +1,168 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { MarkdownRepository } from '../repositories/markdown-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { MarkdownService } from './markdown-service'; + +chai.use(sinonChai); + +describe('MarkdownService', () => { + afterEach(() => { + sinon.restore(); + }); + + it('constructs', () => { + const mockDBConnection = getMockDBConnection(); + + const markdownService = new MarkdownService(mockDBConnection); + + expect(markdownService).to.be.instanceof(MarkdownService); + }); + + describe('getMarkdownByTypeName', () => { + it('should return markdown object for a given type name', async () => { + const mockDBConnection = getMockDBConnection(); + + const mockMarkdownObject = { + markdown_id: 1, + markdown_type_id: 1, + data: 'Sample markdown data', + participated: false + }; + + const getMarkdownByTypeNameStub = sinon + .stub(MarkdownRepository.prototype, 'getMarkdownByTypeName') + .resolves(mockMarkdownObject); + + const markdownService = new MarkdownService(mockDBConnection); + const queryObject = { system_user_id: 1, markdown_type_name: 'example' }; + + const response = await markdownService.getMarkdownByTypeName(queryObject); + + expect(getMarkdownByTypeNameStub).to.be.calledOnceWith(queryObject); + expect(response).to.eql(mockMarkdownObject); + }); + }); + + describe('handleScoreChange', () => { + it('should update the score and return the new score', async () => { + const mockDBConnection = getMockDBConnection(); + + const mockParticipation = null; + + const markdownId = 1; + const systemUserId = 2; + const delta = 1; + + const updateScoreStub = sinon.stub(MarkdownRepository.prototype, 'updateScore').resolves(delta); + const getUserParticipationStub = sinon + .stub(MarkdownRepository.prototype, 'getUserParticipation') + .resolves(mockParticipation); + const insertUserParticipationStub = sinon + .stub(MarkdownRepository.prototype, 'insertUserParticipation') + .resolves(); + + const markdownService = new MarkdownService(mockDBConnection); + + const response = await markdownService.handleScoreChange(markdownId, systemUserId, delta); + + expect(updateScoreStub).to.be.calledOnceWith(markdownId, delta); + expect(getUserParticipationStub).to.be.calledOnceWith(markdownId, systemUserId); + expect(insertUserParticipationStub).to.be.calledOnceWith(markdownId, systemUserId); + + expect(response).to.equal(delta); + }); + + it('should not update the score if the user has already participated', async () => { + const mockDBConnection = getMockDBConnection(); + + const markdownId = 1; + const systemUserId = 2; + + const delta = 1; + const mockParticipation = { markdown_user_id: 1, markdown_id: markdownId, system_user_id: systemUserId }; + + const updateScoreStub = sinon.stub(MarkdownRepository.prototype, 'updateScore').resolves(delta); + const getUserParticipationStub = sinon + .stub(MarkdownRepository.prototype, 'getUserParticipation') + .resolves(mockParticipation); + const insertUserParticipationStub = sinon + .stub(MarkdownRepository.prototype, 'insertUserParticipation') + .resolves(); + + const markdownService = new MarkdownService(mockDBConnection); + + const response = await markdownService.handleScoreChange(markdownId, systemUserId, delta); + + expect(getUserParticipationStub).to.be.calledOnceWith(markdownId, systemUserId); + expect(updateScoreStub).to.not.be.called; + expect(insertUserParticipationStub).to.not.be.called; + + expect(response).to.equal(null); + }); + }); + + describe('updateScore', () => { + it('should update the score and return the new score', async () => { + const mockDBConnection = getMockDBConnection(); + + const mockScore = 5; + const markdownId = 1; + const delta = 1; + + const updateScoreStub = sinon.stub(MarkdownRepository.prototype, 'updateScore').resolves(mockScore); + + const markdownService = new MarkdownService(mockDBConnection); + + const response = await markdownService.updateScore(markdownId, delta); + + expect(updateScoreStub).to.be.calledOnceWith(markdownId, delta); + expect(response).to.equal(mockScore); + }); + }); + + describe('getUserParticipation', () => { + it('should get a markdown user participation record', async () => { + const mockDBConnection = getMockDBConnection(); + + const mockResponse = { markdown_user_id: 1, markdown_id: 3, system_user_id: 2 }; + + const getUserParticipationStub = sinon + .stub(MarkdownRepository.prototype, 'getUserParticipation') + .resolves(mockResponse); + + const markdownService = new MarkdownService(mockDBConnection); + + const response = await markdownService.getUserParticipation( + mockResponse.markdown_id, + mockResponse.system_user_id + ); + + expect(getUserParticipationStub).to.be.calledOnceWith(mockResponse.markdown_id, mockResponse.system_user_id); + expect(response).to.equal(mockResponse); + }); + }); + + describe('insertUserParticipation', () => { + it('should insert user participation and return the result', async () => { + const mockDBConnection = getMockDBConnection(); + + const markdownId = 1; + const systemUserId = 2; + + const insertParticipationResponse = 1; + + const insertUserParticipationStub = sinon + .stub(MarkdownRepository.prototype, 'insertUserParticipation') + .resolves(insertParticipationResponse); + + const markdownService = new MarkdownService(mockDBConnection); + + const response = await markdownService.insertUserParticipation(markdownId, systemUserId); + + expect(insertUserParticipationStub).to.be.calledOnceWith(markdownId, systemUserId); + expect(response).to.equal(insertParticipationResponse); + }); + }); +}); diff --git a/api/src/services/markdown-service.ts b/api/src/services/markdown-service.ts new file mode 100644 index 0000000000..a0a0654088 --- /dev/null +++ b/api/src/services/markdown-service.ts @@ -0,0 +1,90 @@ +import { MarkdownUserRecord } from '../database-models/markdown_user'; +import { IDBConnection } from '../database/db'; +import { MarkdownObject, MarkdownQueryObject } from '../models/markdown-view'; +import { MarkdownRepository } from '../repositories/markdown-repository'; +import { DBService } from './db-service'; + +export class MarkdownService extends DBService { + markdownRepository: MarkdownRepository; + + constructor(connection: IDBConnection) { + super(connection); + this.markdownRepository = new MarkdownRepository(connection); + } + + /** + * Gets the active markdown record for a given markdown type + * + * @param {MarkdownQueryObject} MarkdownQueryObject + * @return {*} Promise + * @memberof MarkdownService + */ + async getMarkdownByTypeName(MarkdownQueryObject: MarkdownQueryObject): Promise { + const response = await this.markdownRepository.getMarkdownByTypeName(MarkdownQueryObject); + + return response; + } + + /** + * Handle a score change for a markdown record, succeeding only if the user has not already scored on the markdown record. + * + * @param {number} markdownId + * @param {number} systemUserId + * @param {number} delta - The amount to change the score by (positive for increase, negative for decrease) + * @return {*} Promise + * @memberof MarkdownService + */ + async handleScoreChange(markdownId: number, systemUserId: number, delta: number): Promise { + // Check if the user has not already scored the markdown record + const participation = await this.getUserParticipation(markdownId, systemUserId); + + // Return null if the user already scored + if (participation) { + return null; + } + + const score = await this.updateScore(markdownId, delta); + + await this.insertUserParticipation(markdownId, systemUserId); + + return score; + } + + /** + * Update the score of a markdown record + * + * @param {number} markdownId + * @param {number} delta - The amount to change the score by (positive for increase, negative for decrease) + * @return {*} Promise + * @memberof MarkdownService + */ + async updateScore(markdownId: number, delta: number): Promise { + return this.markdownRepository.updateScore(markdownId, delta); + } + + /** + * Gets a participation record for a given markdown record and system user id, to check whether a user has already scored a markdown record + * + * @param {number} markdownId + * @param {number} systemUserId + * @return {*} Promise + * @memberof MarkdownService + */ + async getUserParticipation(markdownId: number, systemUserId: number): Promise { + return this.markdownRepository.getUserParticipation(markdownId, systemUserId); + } + + /** + * Insert a record indicating that the user has scored the given markdown record + * + * @param {number} markdownId + * @param {number} systemUserId + * @return {*} Promise + * @memberof MarkdownService + */ + async insertUserParticipation(markdownId: number, systemUserId: number): Promise { + const response = await this.markdownRepository.insertUserParticipation(markdownId, systemUserId); + + return response; + } +} diff --git a/api/src/services/observation-service.test.ts b/api/src/services/observation-service.test.ts index 245df3f0b2..016850d9b3 100644 --- a/api/src/services/observation-service.test.ts +++ b/api/src/services/observation-service.test.ts @@ -8,6 +8,7 @@ import { import * as file_utils from '../utils/file-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; +import { SampleLocationService } from './sample-location-service'; import { SubCountService } from './subcount-service'; chai.use(sinonChai); @@ -73,7 +74,8 @@ describe('ObservationService', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }; const getSurveyObservationsStub = sinon @@ -92,6 +94,10 @@ describe('ObservationService', () => { .stub(SubCountService.prototype, 'getEnvironmentTypeDefinitionsForSurvey') .resolves({ qualitative_environments: [], quantitative_environments: [] }); + const getSampleLocationsForSurveyIdStub = sinon + .stub(SampleLocationService.prototype, 'getSampleLocationsForSurveyId') + .resolves([]); + const surveyId = 1; const observationService = new ObservationService(mockDBConnection); @@ -104,6 +110,7 @@ describe('ObservationService', () => { expect(getSurveyObservationCountStub).to.be.calledOnceWith(surveyId); expect(getMeasurementTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); expect(getEnvironmentTypeDefinitionsForSurveyStub).to.be.calledOnceWith(surveyId); + expect(getSampleLocationsForSurveyIdStub).to.be.calledOnceWith(surveyId); expect(response).to.eql({ surveyObservations: [ { diff --git a/api/src/services/observation-service.ts b/api/src/services/observation-service.ts index 7ddd756fbf..f6dc19f53c 100644 --- a/api/src/services/observation-service.ts +++ b/api/src/services/observation-service.ts @@ -1,3 +1,5 @@ +import dayjs from 'dayjs'; +import { DefaultDateFormat } from '../constants/dates'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; import { IObservationAdvancedFilters } from '../models/observation-view'; @@ -22,6 +24,7 @@ import { InsertObservationSubCountQualitativeMeasurementRecord, InsertObservationSubCountQuantitativeMeasurementRecord } from '../repositories/observation-subcount-measurement-repository'; +import { SampleLocationRecord } from '../repositories/sample-location-repository/sample-location-repository'; import { SamplePeriodHierarchyIds } from '../repositories/sample-period-repository'; import { generateS3FileKey, getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; @@ -65,10 +68,12 @@ import { DBService } from './db-service'; import { ObservationSubCountEnvironmentService } from './observation-subcount-environment-service'; import { ObservationSubCountMeasurementService } from './observation-subcount-measurement-service'; import { PlatformService } from './platform-service'; +import { SampleLocationService } from './sample-location-service'; import { SamplePeriodService } from './sample-period-service'; import { SubCountService } from './subcount-service'; const defaultLog = getLogger('services/observation-service'); +const defaultSubcountSign = 'direct sighting'; /** * An XLSX validation config for the standard columns of an Observation CSV. @@ -79,11 +84,15 @@ const defaultLog = getLogger('services/observation-service'); export const observationStandardColumnValidator = { ITIS_TSN: { type: 'number', aliases: CSV_COLUMN_ALIASES.ITIS_TSN }, COUNT: { type: 'number' }, - OBSERVATION_SUBCOUNT_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SUBCOUNT_SIGN }, - DATE: { type: 'date' }, - TIME: { type: 'string' }, - LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE }, - LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE } + OBSERVATION_SUBCOUNT_SIGN: { type: 'code', aliases: CSV_COLUMN_ALIASES.OBSERVATION_SUBCOUNT_SIGN, optional: true }, + DATE: { type: 'date', optional: true }, + TIME: { type: 'string', optional: true }, + LATITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LATITUDE, optional: true }, + LONGITUDE: { type: 'number', aliases: CSV_COLUMN_ALIASES.LONGITUDE, optional: true }, + SAMPLING_SITE: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_SITE, optional: true }, + SAMPLING_METHOD: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_METHOD, optional: true }, + SAMPLING_PERIOD: { type: 'string', aliases: CSV_COLUMN_ALIASES.SAMPLING_PERIOD, optional: true }, + COMMENT: { type: 'string', aliases: CSV_COLUMN_ALIASES.COMMENT, optional: true } } satisfies IXLSXCSVValidator; export const getColumnCellValue = generateColumnCellGetterFromColumnValidator(observationStandardColumnValidator); @@ -91,6 +100,7 @@ export const getColumnCellValue = generateColumnCellGetterFromColumnValidator(ob export interface InsertSubCount { observation_subcount_id: number | null; observation_subcount_sign_id: number | null; + comment: string | null; subcount: number; qualitative_measurements: { measurement_id: string; @@ -126,8 +136,13 @@ export type ObservationMeasurementSupplementaryData = { quantitative_environments: QuantitativeEnvironmentTypeDefinition[]; }; +export type ObservationSamplingSupplementaryData = { + sample_sites: SampleLocationRecord[]; +}; + export type AllObservationSupplementaryData = ObservationCountSupplementaryData & - ObservationMeasurementSupplementaryData; + ObservationMeasurementSupplementaryData & + ObservationSamplingSupplementaryData; export class ObservationService extends DBService { observationRepository: ObservationRepository; @@ -192,7 +207,8 @@ export class ObservationService extends DBService { survey_observation_id: surveyObservationId, // NOTE: The UI currently only allows one subcount per observation, so the standardColumns count can be used subcount: observation.subcounts.length === 1 ? observation.standardColumns.count : subcount.subcount, - observation_subcount_sign_id: subcount.observation_subcount_sign_id + observation_subcount_sign_id: subcount.observation_subcount_sign_id, + comment: subcount.comment }); if (!observation.subcounts.length) { @@ -295,16 +311,22 @@ export class ObservationService extends DBService { surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; supplementaryObservationData: AllObservationSupplementaryData; }> { + const sampleLocationService = new SampleLocationService(this.connection); const surveyObservations = await this.observationRepository.getSurveyObservationsWithSamplingDataWithAttributesData( surveyId, pagination ); + const sampleSiteIds = surveyObservations + .filter((obs) => obs.survey_sample_site_id) + .map((observation) => observation.survey_sample_site_id!); + // Get supplementary observation data const observationCount = await this.observationRepository.getSurveyObservationCount(surveyId); const subCountService = new SubCountService(this.connection); const measurementTypeDefinitions = await subCountService.getMeasurementTypeDefinitionsForSurvey(surveyId); const environmentTypeDefinitions = await subCountService.getEnvironmentTypeDefinitionsForSurvey(surveyId); + const sampleLocations = await sampleLocationService.getSampleLocationsForSurveyId(surveyId, { sampleSiteIds }); return { surveyObservations: surveyObservations, @@ -313,7 +335,8 @@ export class ObservationService extends DBService { qualitative_measurements: measurementTypeDefinitions.qualitative_measurements, quantitative_measurements: measurementTypeDefinitions.quantitative_measurements, qualitative_environments: environmentTypeDefinitions.qualitative_environments, - quantitative_environments: environmentTypeDefinitions.quantitative_environments + quantitative_environments: environmentTypeDefinitions.quantitative_environments, + sample_sites: sampleLocations } }; } @@ -542,9 +565,7 @@ export class ObservationService extends DBService { }); // Fetch all measurement type definitions from Critterbase for all unique TSNs - const tsns = worksheetRowObjects.map((row) => - String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']) - ); + const tsns = worksheetRowObjects.map((row) => String(getColumnCellValue(row, 'ITIS_TSN').cell)); const tsnMeasurementTypeDefinitionMap = await getTsnMeasurementTypeDefinitionMap(tsns, critterBaseService); @@ -553,7 +574,7 @@ export class ObservationService extends DBService { const measurementsToValidate: IMeasurementDataToValidate[] = worksheetRowObjects.flatMap((row) => { return measurementColumnNames.map((columnName) => ({ - tsn: String(row['ITIS_TSN'] ?? row['TSN'] ?? row['TAXON'] ?? row['SPECIES']), + tsn: String(getColumnCellValue(row, 'ITIS_TSN').cell), key: columnName, value: row[columnName] })); @@ -598,8 +619,15 @@ export class ObservationService extends DBService { throw new Error('Failed to process file for importing observations. Environment column validator failed.'); } - // ----------------------------------------------------------------------------------------- + // SAMPLING INFORMATION ----------------------------------------------------------------------------------------- + const sampleLocationService = new SampleLocationService(this.connection); + // Get sampling information for the survey to later validate + const samplingLocations = await sampleLocationService.getSampleLocationsForSurveyId(surveyId); + + // -------------------------------------------------------------------------------------------------------------- + + // SamplePeriodHierarchyIds is only for when all records are being assigned to the same sampling period let samplePeriodHierarchyIds: SamplePeriodHierarchyIds; if (options?.surveySamplePeriodId) { @@ -610,20 +638,25 @@ export class ObservationService extends DBService { ); } + // Get subcount sign options and default option for when sign is null + const codeMap = new Map( + codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN.map((option) => [option.name.toLowerCase(), option.id]) + ); + const defaultSubcountSignId = codeMap.get(defaultSubcountSign) || null; + // Merge all the table rows into an array of InsertUpdateObservations[] const newRowData: InsertUpdateObservations[] = worksheetRowObjects.map((row) => { - // TODO: This observationSubcountSignId logic is specifically catered to the observation_subcount_signs code set, - // as it is the only code set currently being used in the observation CSVs, and is required. This logic will need - // to be updated to be more generic if other code sets are used in the future, or if they can be nullable. - const observationSubcountSignId = codeTypeDefinitions.OBSERVATION_SUBCOUNT_SIGN.find( - (option) => - option.name.toLowerCase() === getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN')?.cell?.toLowerCase() - )?.id; + const observationSubcountSignId = this._getCodeIdFromCellValue( + getColumnCellValue(row, 'OBSERVATION_SUBCOUNT_SIGN').cell, + codeMap, + defaultSubcountSignId + ); const newSubcount: InsertSubCount = { observation_subcount_id: null, subcount: getColumnCellValue(row, 'COUNT').cell as number, observation_subcount_sign_id: observationSubcountSignId ?? null, + comment: (getColumnCellValue(row, 'COMMENT').cell as string) ?? null, qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], @@ -646,14 +679,41 @@ export class ObservationService extends DBService { newSubcount.qualitative_environments = environments.qualitative_environments; newSubcount.quantitative_environments = environments.quantitative_environments; + // If surveySamplePeriodId was included in the initial request, assign all rows to that sampling period + if (options?.surveySamplePeriodId) { + return { + standardColumns: { + survey_id: surveyId, + itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, + itis_scientific_name: null, + survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, + survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, + survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, + latitude: getColumnCellValue(row, 'LATITUDE').cell as number, + longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, + count: getColumnCellValue(row, 'COUNT').cell as number, + observation_time: getColumnCellValue(row, 'TIME').cell as string, + observation_date: getColumnCellValue(row, 'DATE').cell as string + }, + subcounts: [newSubcount] + }; + } + + // PROCESS AND VALIDATE SAMPLING INFORMATION ----------------------------------------------------------------------------------------- + const samplingData = this._pullSamplingDataFromWorksheetRowObject(row, samplingLocations); + + if (!samplingData && getColumnCellValue(row, 'SAMPLING_SITE').cell) { + throw new Error('Failed to process file for importing observations. Sampling data validator failed.'); + } + return { standardColumns: { survey_id: surveyId, itis_tsn: getColumnCellValue(row, 'ITIS_TSN').cell as number, itis_scientific_name: null, - survey_sample_site_id: samplePeriodHierarchyIds?.survey_sample_site_id ?? null, - survey_sample_method_id: samplePeriodHierarchyIds?.survey_sample_method_id ?? null, - survey_sample_period_id: samplePeriodHierarchyIds?.survey_sample_period_id ?? null, + survey_sample_site_id: samplingData?.sampleSiteId ?? null, + survey_sample_method_id: samplingData?.sampleMethodId ?? null, + survey_sample_period_id: samplingData?.samplePeriodId ?? null, latitude: getColumnCellValue(row, 'LATITUDE').cell as number, longitude: getColumnCellValue(row, 'LONGITUDE').cell as number, count: getColumnCellValue(row, 'COUNT').cell as number, @@ -774,7 +834,7 @@ export class ObservationService extends DBService { // if environment is qualitative, find the option id if (isEnvironmentQualitativeTypeDefinition(environment)) { - const foundOption = environment.options.find((option) => option.name === String(rowData)); + const foundOption = environment.options.find((option) => option.name === String(rowData).toLowerCase()); if (!foundOption) { return; @@ -795,6 +855,136 @@ export class ObservationService extends DBService { return foundEnvironments; } + /** + * Extracts sampling data from the worksheet row object and maps site names, method techniques, and periods + * to their respective IDs using the provided samplingLocations. + * + * @param {Record} row - The current row of the worksheet being processed. + * @param {SampleLocationRecord[]} samplingLocations - The available sampling locations for the survey, used for mapping names to IDs. + * @return { { sampleSiteId: number, sampleMethodId: number, samplePeriodId: number } | null } The sampling data with IDs, or null if no valid data is found. + */ + _pullSamplingDataFromWorksheetRowObject( + row: Record, + samplingLocations: SampleLocationRecord[] + ): { sampleSiteId: number; sampleMethodId: number; samplePeriodId: number } | null { + // Extract site, method, and period data from the row + const siteName = getColumnCellValue(row, 'SAMPLING_SITE').cell as string | null; + const techniqueName = getColumnCellValue(row, 'SAMPLING_METHOD').cell as string | null; + const period = getColumnCellValue(row, 'SAMPLING_PERIOD').cell as string | null; + + if (!siteName) { + return null; + } + + // Find the site record by name + const siteRecord = samplingLocations.find((site) => site.name.toLowerCase() === siteName.toLowerCase()); + + // If there is no site, exit early because a site is required when specifying any sampling information for the row. + if (!siteRecord) { + return null; + } + + let methodRecord = null; + + // Find the method record by technique name + if (techniqueName) { + methodRecord = siteRecord.sample_methods.find( + (method) => method.technique.name.toLowerCase() === techniqueName.toLowerCase() + ); + } + + // If we failed to find a method record based on technique name, we will check whether that site has just 1 technique. + // If the site has 1 technique, we will assume that the row belongs to that technique. + // This is a convenience for users because they only need to specify the sampling site for sites with 1 technique. + if (siteRecord.sample_methods.length === 1) { + methodRecord = siteRecord.sample_methods[0]; + } + + // If there are multiple techniques for the site but no technique specified in the row, + // exit early because we cannot determine which method to use. + if (!methodRecord) { + return null; + } + + // If period is specified, parse the row value and find a matching record + if (period) { + // Format the period timestamp data + const [startDate, endDate] = period.split('-').map((date: string) => dayjs(date).format(DefaultDateFormat)); + const startTime = dayjs(period.split('-')[0]).format('HH:mm:ss'); + const endTime = dayjs(period.split('-')[1]).format('HH:mm:ss'); + + // Find matching periods by date + const matchingPeriods = methodRecord.sample_periods.filter( + (p) => p.start_date === startDate && p.end_date === endDate + ); + + // Return if exactly one period matches the date, + // meaning that we have successfully determined a single site, method, and period Id for each row. + if (matchingPeriods.length === 1) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: matchingPeriods[0].survey_sample_period_id + }; + } + + // If multiple periods match by date, try to match also by time + const matchingPeriod = matchingPeriods.find((p) => p.start_time === startTime && p.end_time === endTime); + + if (matchingPeriod) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: matchingPeriod.survey_sample_period_id + }; + } + } + + // If period is not specified, infer it from the row data + const observationDate = getColumnCellValue(row, 'DATE').cell as string | null; + const observationTime = getColumnCellValue(row, 'TIME').cell as string | null; + + const formattedDate = dayjs(observationDate); + const formattedTime = dayjs(`${observationDate} ${observationTime}`).format('HH:mm:ss'); + + // TODO: Fix timezone of the observation date. Observation date is assumed to be UTC instead of local time, + // so the observation date being imported from the csv is incorrectly offset by 1 day. eg. "July 28, 2024" is + // imported at July 27, 2024 + // + // If no periods match by date/time but the site and method is given, check if the observation date falls within a period. + // If true, we will infer the period based on the observation date. + const encompassingPeriod = methodRecord.sample_periods.find( + (p) => + (formattedDate.isAfter(dayjs(p.start_date)) || formattedDate.isSame(dayjs(p.start_date))) && + (formattedDate.isBefore(dayjs(p.end_date)) || formattedDate.isAfter(dayjs(p.end_date))) && + (!p.start_time || formattedTime >= dayjs(`${p.start_date} ${p.start_time}`).format('HH:mm:ss')) && + (!p.end_time || formattedTime <= dayjs(`${p.end_date} ${p.end_time}`).format('HH:mm:ss')) + ); + + if (encompassingPeriod) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: encompassingPeriod.survey_sample_period_id + }; + } + + // If there is no observation date and exactly 1 period for the matching method, and there is no period specified in the row, + // we will assume that the observation belongs to that period. This is a convenience for users since they don't need to specify + // the period if they have only 1 for the matching method. + // TODO: Might be worth checking if (!observationDate && methodRecord.sample_periods.length === 1), therefore + // failing if the specified date is not in a period + if (methodRecord.sample_periods.length === 1) { + return { + sampleSiteId: siteRecord.survey_sample_site_id, + sampleMethodId: methodRecord.survey_sample_method_id, + samplePeriodId: methodRecord.sample_periods[0].survey_sample_period_id + }; + } + + return null; + } + /** * Maps over an array of inserted/updated observation records in order to update its scientific * name to match its ITIS TSN. @@ -1015,4 +1205,29 @@ export class ObservationService extends DBService { // Return true if both environments and measurements are valid return true; } + + /** + * Gets the code id value with a matching name from a pre-mapped set of options. If the function returns null, the + * request should probably throw an error. + * + * @param cellValue The name of a code to find the id for + * @param codeMap A Map where the key is the normalized code name and the value is the ID + * @param defaultCodeId A precomputed default code ID for cases where cellValue is null + * @returns The ID of the matching code, or the default ID, or null if no match is found + */ + _getCodeIdFromCellValue( + cellValue: string | null, + codeMap: Map, + defaultCodeId?: number | null + ): number | null { + const value = cellValue?.toLowerCase(); // Normalize the cell value + + // If no value exists, return the default code ID or null + if (!value) { + return defaultCodeId ?? null; + } + + // Return the ID from the map if it exists, otherwise return null + return codeMap.get(value) ?? null; + } } diff --git a/api/src/services/sample-location-service.test.ts b/api/src/services/sample-location-service.test.ts index 57a17b7dcc..399c2abad0 100644 --- a/api/src/services/sample-location-service.test.ts +++ b/api/src/services/sample-location-service.test.ts @@ -3,7 +3,7 @@ import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { UpdateSampleBlockRecord } from '../repositories/sample-blocks-repository'; -import { SampleLocationRepository } from '../repositories/sample-location-repository'; +import { SampleLocationRepository } from '../repositories/sample-location-repository/sample-location-repository'; import { UpdateSampleStratumRecord } from '../repositories/sample-stratums-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { SampleBlockService } from './sample-block-service'; @@ -84,7 +84,7 @@ describe('SampleLocationService', () => { name: 'Sample Site 1', description: '', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, @@ -112,7 +112,7 @@ describe('SampleLocationService', () => { survey_id: 1, name: 'Sample Site 1', description: '', - geojson: [], + geometry_type: 'Point', blocks: [], sample_methods: [], stratums: [] @@ -141,6 +141,50 @@ describe('SampleLocationService', () => { }); }); + describe('getSampleLocationsGeometryBySurveyId', () => { + it('should return the sample site geometries successfully', async () => { + const dbConnectionObj = getMockDBConnection(); + + const mockRows = [{ survey_sample_site_id: 1, geojson: {} }]; + + const repoStub = sinon + .stub(SampleLocationRepository.prototype, 'getSampleLocationsGeometryBySurveyId') + .resolves(mockRows); + + const sampleLocationService = new SampleLocationService(dbConnectionObj); + const response = await sampleLocationService.getSampleLocationsGeometryBySurveyId(1001); + + expect(repoStub).to.be.calledOnceWith(1001); + expect(response).to.eql(mockRows); + }); + }); + + describe('getBasicSurveySampleLocationsBySiteIds', () => { + it('should successfully return sampling location records with basic data', async () => { + const dbConnectionObj = getMockDBConnection(); + + const mockSurveySampleSiteIds = [1, 2]; + const mockRows = mockSurveySampleSiteIds.map((site) => ({ + survey_sample_site_id: site, + name: '', + sample_methods: [] + })); + + const repoStub = sinon + .stub(SampleLocationRepository.prototype, 'getBasicSurveySampleLocationsBySiteIds') + .resolves(mockRows); + + const sampleLocationService = new SampleLocationService(dbConnectionObj); + const response = await sampleLocationService.getBasicSurveySampleLocationsBySiteIds( + 1001, + mockSurveySampleSiteIds + ); + + expect(repoStub).to.be.calledOnceWith(1001); + expect(response).to.eql(mockRows); + }); + }); + describe('deleteSampleSiteRecord', () => { it('should run without issue', async () => { const mockDBConnection = getMockDBConnection(); @@ -183,7 +227,7 @@ describe('SampleLocationService', () => { name: 'Sample Site 1', description: '', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, @@ -263,7 +307,7 @@ describe('SampleLocationService', () => { name: 'Cool new site', description: 'Check out this description', geometry: null, - geography: [], + geography: '', geojson: [], create_date: '', create_user: 1, diff --git a/api/src/services/sample-location-service.ts b/api/src/services/sample-location-service.ts index 0eb3f45d67..d31e3a2a6d 100644 --- a/api/src/services/sample-location-service.ts +++ b/api/src/services/sample-location-service.ts @@ -1,12 +1,21 @@ +import { SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; +import { SurveySampleSiteModel } from '../database-models/survey_sample_site'; import { IDBConnection } from '../database/db'; +import { + IMethodAdvancedFilters, + IPeriodAdvancedFilters, + ISiteAdvancedFilters +} from '../models/sampling-locations-view'; import { InsertSampleBlockRecord } from '../repositories/sample-blocks-repository'; import { + FindSampleSiteRecord, InsertSampleSiteRecord, + SampleLocationBasicRecord, SampleLocationRecord, SampleLocationRepository, - SampleSiteRecord, + SampleSiteGeometryRecord, UpdateSampleLocationRecord -} from '../repositories/sample-location-repository'; +} from '../repositories/sample-location-repository/sample-location-repository'; import { InsertSampleMethodRecord } from '../repositories/sample-method-repository'; import { InsertSampleStratumRecord } from '../repositories/sample-stratums-repository'; import { getLogger } from '../utils/logger'; @@ -46,15 +55,23 @@ export class SampleLocationService extends DBService { * Gets a paginated set of survey Sample Locations for the given survey. * * @param {number} surveyId - * @param {ApiPaginationOptions} [pagination] + * @param {{ + * keyword?: string; + * sampleSiteIds?: number[]; + * pagination?: ApiPaginationOptions; + * }} [options] * @return {*} {Promise} * @memberof SampleLocationService */ async getSampleLocationsForSurveyId( surveyId: number, - pagination?: ApiPaginationOptions + options?: { + keyword?: string; + sampleSiteIds?: number[]; + pagination?: ApiPaginationOptions; + } ): Promise { - return this.sampleLocationRepository.getSampleLocationsForSurveyId(surveyId, pagination); + return this.sampleLocationRepository.getSampleLocationsForSurveyId(surveyId, options); } /** @@ -68,18 +85,44 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSampleLocationsCountBySurveyId(surveyId); } + /** + * Returns the geometry for all sampling locations in the Survey + * + * @param {number} surveyId + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getSampleLocationsGeometryBySurveyId(surveyId: number): Promise { + return this.sampleLocationRepository.getSampleLocationsGeometryBySurveyId(surveyId); + } + /** * Gets a sample site record by sample site ID. * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { + async getSurveySampleSiteById(surveyId: number, surveySampleSiteId: number): Promise { return this.sampleLocationRepository.getSurveySampleSiteById(surveyId, surveySampleSiteId); } + /** + * Gets basic data for survey sample sites for supplementary observations data + * + * @param {number} surveyId + * @param {number[]} surveySampleSiteIds + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async getBasicSurveySampleLocationsBySiteIds( + surveyId: number, + surveySampleSiteIds: number[] + ): Promise { + return this.sampleLocationRepository.getBasicSurveySampleLocationsBySiteIds(surveyId, surveySampleSiteIds); + } + /** * Gets a sample location by sample site ID. * @@ -92,15 +135,111 @@ export class SampleLocationService extends DBService { return this.sampleLocationRepository.getSurveySampleLocationBySiteId(surveyId, surveySampleSiteId); } + /** + * Retrieves the paginated list of all sites that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISiteAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findSites( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + return this.sampleLocationRepository.findSites(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the count of all sites that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {ISiteAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findSitesCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: ISiteAdvancedFilters + ): Promise { + return this.sampleLocationRepository.findSitesCount(isUserAdmin, systemUserId, filterFields); + } + + /** + * Retrieves the paginated list of all methods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IMethodAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} + * @memberof SampleLocationService + */ + async findMethods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IMethodAdvancedFilters, + pagination?: ApiPaginationOptions + ) { + return this.sampleLocationRepository.findMethods(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the paginated list of all periods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IPeriodAdvancedFilters} filterFields + * @param {ApiPaginationOptions} [pagination] + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findPeriods( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters, + pagination?: ApiPaginationOptions + ): Promise { + return this.sampleLocationRepository.findPeriods(isUserAdmin, systemUserId, filterFields, pagination); + } + + /** + * Retrieves the count of all periods that are available to the user, based on their permissions and + * provided filter criteria. + * + * @param {boolean} isUserAdmin + * @param {(number | null)} systemUserId The system user id of the user making the request + * @param {IPeriodAdvancedFilters} filterFields + * @return {*} {Promise} + * @memberof SampleLocationService + */ + async findPeriodsCount( + isUserAdmin: boolean, + systemUserId: number | null, + filterFields: IPeriodAdvancedFilters + ): Promise { + return this.sampleLocationRepository.findPeriodsCount(isUserAdmin, systemUserId, filterFields); + } + /** * Deletes a survey Sample Location. * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { + async deleteSampleSiteRecord(surveyId: number, surveySampleSiteId: number): Promise { const sampleMethodService = new SampleMethodService(this.connection); const sampleBlockService = new SampleBlockService(this.connection); const sampleStratumService = new SampleStratumService(this.connection); @@ -144,10 +283,10 @@ export class SampleLocationService extends DBService { * integer id + 1 in the db. * * @param {PostSampleLocations} sampleLocations - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleLocationService */ - async insertSampleLocations(sampleLocations: PostSampleLocations): Promise { + async insertSampleLocations(sampleLocations: PostSampleLocations): Promise { defaultLog.debug({ label: 'insertSampleLocations' }); // Create a sample site record for each feature found diff --git a/api/src/services/sample-method-service.test.ts b/api/src/services/sample-method-service.test.ts index 4ca9bf77b7..21c8c3b9f2 100644 --- a/api/src/services/sample-method-service.test.ts +++ b/api/src/services/sample-method-service.test.ts @@ -1,13 +1,13 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { SurveySampleMethodModel } from '../database-models/survey_sample_method'; +import { SurveySamplePeriodModel, SurveySamplePeriodRecord } from '../database-models/survey_sample_period'; import { InsertSampleMethodRecord, - SampleMethodRecord, SampleMethodRepository, UpdateSampleMethodRecord } from '../repositories/sample-method-repository'; -import { SamplePeriodRecord } from '../repositories/sample-period-repository'; import { getMockDBConnection } from '../__mocks__/db'; import { ObservationService } from './observation-service'; import { SampleMethodService } from './sample-method-service'; @@ -32,7 +32,7 @@ describe('SampleMethodService', () => { it('Gets a sample method by survey sample site ID', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecords: SampleMethodRecord[] = [ + const mockSampleMethodRecords: SurveySampleMethodModel[] = [ { survey_sample_method_id: 1, survey_sample_site_id: 2, @@ -98,7 +98,7 @@ describe('SampleMethodService', () => { const mockSamplePeriodId = 1; const mockSampleMethodId = 1; - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -116,7 +116,7 @@ describe('SampleMethodService', () => { sinon .stub(SamplePeriodService.prototype, 'getSamplePeriodsForSurveyMethodId') - .resolves([{ survey_sample_period_id: mockSamplePeriodId } as SamplePeriodRecord]); + .resolves([{ survey_sample_period_id: mockSamplePeriodId } as SurveySamplePeriodModel]); const deleteSamplePeriodRecordStub = sinon .stub(SamplePeriodService.prototype, 'deleteSamplePeriodRecords') .resolves(); @@ -138,7 +138,7 @@ describe('SampleMethodService', () => { it('Inserts a sample method successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -154,7 +154,7 @@ describe('SampleMethodService', () => { .stub(SampleMethodRepository.prototype, 'insertSampleMethod') .resolves(mockSampleMethodRecord); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_method_id: 1, survey_sample_period_id: 2, start_date: '2023-10-04', @@ -223,7 +223,7 @@ describe('SampleMethodService', () => { it('Updates a sample method successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: 1, survey_sample_site_id: 2, method_technique_id: 3, @@ -265,7 +265,7 @@ describe('SampleMethodService', () => { start_time: '12:00:00', end_time: '13:00:00', survey_sample_method_id: 1 - } as SamplePeriodRecord + } as SurveySamplePeriodRecord ] }; const sampleMethodService = new SampleMethodService(mockDBConnection); @@ -287,7 +287,7 @@ describe('SampleMethodService', () => { const mockSampleMethodId = 1; const surveySampleSiteId = 1; - const mockSampleMethodRecord: SampleMethodRecord = { + const mockSampleMethodRecord: SurveySampleMethodModel = { survey_sample_method_id: mockSampleMethodId, survey_sample_site_id: 2, method_technique_id: 3, @@ -300,7 +300,7 @@ describe('SampleMethodService', () => { revision_count: 0 }; - const mockSampleMethodRecords: SampleMethodRecord[] = [mockSampleMethodRecord]; + const mockSampleMethodRecords: SurveySampleMethodModel[] = [mockSampleMethodRecord]; const getSampleMethodsForSurveySampleSiteIdStub = sinon .stub(SampleMethodRepository.prototype, 'getSampleMethodsForSurveySampleSiteId') .resolves(mockSampleMethodRecords); diff --git a/api/src/services/sample-method-service.ts b/api/src/services/sample-method-service.ts index ad6e2f7a0f..7de513b0aa 100644 --- a/api/src/services/sample-method-service.ts +++ b/api/src/services/sample-method-service.ts @@ -1,8 +1,8 @@ +import { SurveySampleMethodModel } from '../database-models/survey_sample_method'; import { IDBConnection } from '../database/db'; import { HTTP409 } from '../errors/http-error'; import { InsertSampleMethodRecord, - SampleMethodRecord, SampleMethodRepository, UpdateSampleMethodRecord } from '../repositories/sample-method-repository'; @@ -30,13 +30,13 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ async getSampleMethodsForSurveySampleSiteId( surveyId: number, surveySampleSiteId: number - ): Promise { + ): Promise { return this.sampleMethodRepository.getSampleMethodsForSurveySampleSiteId(surveyId, surveySampleSiteId); } @@ -56,10 +56,10 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { + async deleteSampleMethodRecord(surveyId: number, surveySampleMethodId: number): Promise { const samplePeriodService = new SamplePeriodService(this.connection); // Collect list of periods to delete @@ -78,10 +78,10 @@ export class SampleMethodService extends DBService { * Inserts survey Sample Method and associated Sample Periods. * * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { + async insertSampleMethod(sampleMethod: InsertSampleMethodRecord): Promise { // Create new sample method const sampleMethodRecord = await this.sampleMethodRepository.insertSampleMethod(sampleMethod); @@ -156,10 +156,10 @@ export class SampleMethodService extends DBService { * * @param {number} surveyId * @param {InsertSampleMethodRecord} sampleMethod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SampleMethodService */ - async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { + async updateSampleMethod(surveyId: number, sampleMethod: UpdateSampleMethodRecord): Promise { const samplePeriodService = new SamplePeriodService(this.connection); // Check for any sample periods to delete diff --git a/api/src/services/sample-period-service.test.ts b/api/src/services/sample-period-service.test.ts index 7306048d27..a59fc19ab4 100644 --- a/api/src/services/sample-period-service.test.ts +++ b/api/src/services/sample-period-service.test.ts @@ -1,10 +1,10 @@ import chai, { expect } from 'chai'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import { SurveySamplePeriodModel } from '../database-models/survey_sample_period'; import { InsertSamplePeriodRecord, SamplePeriodHierarchyIds, - SamplePeriodRecord, SamplePeriodRepository, UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; @@ -31,7 +31,7 @@ describe('SamplePeriodService', () => { it('Gets a sample period by survey method ID', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecords: SamplePeriodRecord[] = [ + const mockSamplePeriodRecords: SurveySamplePeriodModel[] = [ { survey_sample_period_id: 1, survey_sample_method_id: 2, @@ -97,7 +97,7 @@ describe('SamplePeriodService', () => { it('Deletes a sample period record', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -132,7 +132,7 @@ describe('SamplePeriodService', () => { it('Inserts a sample period successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -172,7 +172,7 @@ describe('SamplePeriodService', () => { it('Updates a sample period successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecord: SamplePeriodRecord = { + const mockSamplePeriodRecord: SurveySamplePeriodModel = { survey_sample_period_id: 1, survey_sample_method_id: 2, start_date: '2023-10-02', @@ -211,7 +211,7 @@ describe('SamplePeriodService', () => { it('should delete sample sites not in array successfully', async () => { const mockDBConnection = getMockDBConnection(); - const mockSamplePeriodRecords: SamplePeriodRecord[] = [ + const mockSamplePeriodRecords: SurveySamplePeriodModel[] = [ { survey_sample_period_id: 1, survey_sample_method_id: 2, @@ -242,7 +242,7 @@ describe('SamplePeriodService', () => { const surveySampleMethodId = 1; const samplePeriodService = new SamplePeriodService(mockDBConnection); const response = await samplePeriodService.deleteSamplePeriodsNotInArray(mockSurveyId, surveySampleMethodId, [ - { survey_sample_period_id: 2 } as SamplePeriodRecord + { survey_sample_period_id: 2 } as SurveySamplePeriodModel ]); expect(getSamplePeriodsForSurveyMethodIdStub).to.be.calledOnceWith(mockSurveyId, surveySampleMethodId); diff --git a/api/src/services/sample-period-service.ts b/api/src/services/sample-period-service.ts index 38cd96d8c7..8a7307fcca 100644 --- a/api/src/services/sample-period-service.ts +++ b/api/src/services/sample-period-service.ts @@ -1,9 +1,9 @@ +import { SurveySamplePeriodModel } from '../database-models/survey_sample_period'; import { IDBConnection } from '../database/db'; import { HTTP409 } from '../errors/http-error'; import { InsertSamplePeriodRecord, SamplePeriodHierarchyIds, - SamplePeriodRecord, SamplePeriodRepository, UpdateSamplePeriodRecord } from '../repositories/sample-period-repository'; @@ -30,13 +30,13 @@ export class SamplePeriodService extends DBService { * * @param {number} surveyId * @param {number} surveySampleMethodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ async getSamplePeriodsForSurveyMethodId( surveyId: number, surveySampleMethodId: number - ): Promise { + ): Promise { return this.samplePeriodRepository.getSamplePeriodsForSurveyMethodId(surveyId, surveySampleMethodId); } @@ -57,10 +57,10 @@ export class SamplePeriodService extends DBService { * * @param {number} surveyId * @param {number} surveySamplePeriodId - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { + async deleteSamplePeriodRecord(surveyId: number, surveySamplePeriodId: number): Promise { return this.samplePeriodRepository.deleteSamplePeriodRecord(surveyId, surveySamplePeriodId); } @@ -68,10 +68,10 @@ export class SamplePeriodService extends DBService { * Deletes multiple Survey Sample Periods for a given array of period ids. * * @param {number[]} periodsToDelete an array of period ids to delete - * @returns {*} {Promise} an array of promises for the deleted periods + * @returns {*} {Promise} an array of promises for the deleted periods * @memberof SamplePeriodService */ - async deleteSamplePeriodRecords(surveyId: number, periodsToDelete: number[]): Promise { + async deleteSamplePeriodRecords(surveyId: number, periodsToDelete: number[]): Promise { return this.samplePeriodRepository.deleteSamplePeriods(surveyId, periodsToDelete); } @@ -79,10 +79,10 @@ export class SamplePeriodService extends DBService { * Inserts survey Sample Period. * * @param {InsertSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async insertSamplePeriod(samplePeriod: InsertSamplePeriodRecord): Promise { + async insertSamplePeriod(samplePeriod: InsertSamplePeriodRecord): Promise { return this.samplePeriodRepository.insertSamplePeriod(samplePeriod); } @@ -90,10 +90,10 @@ export class SamplePeriodService extends DBService { * updates a survey Sample Period. * * @param {UpdateSamplePeriodRecord} samplePeriod - * @return {*} {Promise} + * @return {*} {Promise} * @memberof SamplePeriodService */ - async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { + async updateSamplePeriod(surveyId: number, samplePeriod: UpdateSamplePeriodRecord): Promise { return this.samplePeriodRepository.updateSamplePeriod(surveyId, samplePeriod); } diff --git a/api/src/services/survey-block-service.test.ts b/api/src/services/survey-block-service.test.ts index a2e1017c06..9a5dd094a4 100644 --- a/api/src/services/survey-block-service.test.ts +++ b/api/src/services/survey-block-service.test.ts @@ -68,8 +68,30 @@ describe('SurveyBlockService', () => { const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); const blocks: PostSurveyBlock[] = [ - { survey_block_id: null, survey_id: 1, name: 'Old Block', description: 'Updated' }, - { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + { + survey_block_id: null, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }, + { + survey_block_id: null, + survey_id: 1, + name: 'New Block', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + } ]; await service.upsertSurveyBlocks(1, blocks); @@ -106,8 +128,30 @@ describe('SurveyBlockService', () => { const updateBlock = sinon.stub(SurveyBlockRepository.prototype, 'updateSurveyBlock').resolves(); const blocks: PostSurveyBlock[] = [ - { survey_block_id: 10, survey_id: 1, name: 'Old Block', description: 'Updated' }, - { survey_block_id: null, survey_id: 1, name: 'New Block', description: 'block' } + { + survey_block_id: 10, + survey_id: 1, + name: 'Old Block', + description: 'Updated', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + }, + { + survey_block_id: null, + survey_id: 1, + name: 'New Block', + description: 'block', + geojson: { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: {}, + id: 'testid1' + } + } ]; await service.upsertSurveyBlocks(1, blocks); diff --git a/api/src/services/technique-attributes-service.ts b/api/src/services/technique-attributes-service.ts index 961c77ef5b..4a66b76c81 100644 --- a/api/src/services/technique-attributes-service.ts +++ b/api/src/services/technique-attributes-service.ts @@ -207,17 +207,13 @@ export class TechniqueAttributeService extends DBService { // If the incoming data does have method_technique_attribute_quantitative_id, record is for update const attributesForUpdate = attributes.filter((attribute) => attribute.method_technique_attribute_quantitative_id); - const promises = []; - if (attributesForUpdate.length > 0) { - promises.push( + await Promise.all( attributesForUpdate.map((attribute) => this.techniqueAttributeRepository.updateQuantitativeAttributeForTechnique(methodTechniqueId, attribute) ) ); } - - await Promise.all(promises); } /** diff --git a/api/src/services/telemetry-service.ts b/api/src/services/telemetry-service.ts index 1d235ca823..86d39e521b 100644 --- a/api/src/services/telemetry-service.ts +++ b/api/src/services/telemetry-service.ts @@ -1,4 +1,5 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { DefaultDateFormat, DefaultTimeFormat } from '../constants/dates'; import { IDBConnection } from '../database/db'; import { ApiGeneralError } from '../errors/api-error'; import { IAllTelemetryAdvancedFilters } from '../models/telemetry-view'; @@ -149,13 +150,15 @@ export class TelemetryService extends DBService { if (foundDeployment) { itemsToAdd.push({ deployment_id: foundDeployment.deployment_id, - acquisition_date: dateTime.format('YYYY-MM-DD HH:mm:ss'), + acquisition_date: dateTime.format(`${DefaultDateFormat} ${DefaultTimeFormat}`), latitude: row['LATITUDE'], longitude: row['LONGITUDE'] }); } else { throw new ApiGeneralError( - `No deployment was found for device: ${deviceId} on: ${dateTime.format('YYYY-MM-DD HH:mm:ss')}` + `No deployment was found for device: ${deviceId} on: ${dateTime.format( + `${DefaultDateFormat} ${DefaultTimeFormat}` + )}` ); } }); diff --git a/api/src/services/vantage-mode-service.test.ts b/api/src/services/vantage-mode-service.test.ts new file mode 100644 index 0000000000..c8ff4494c6 --- /dev/null +++ b/api/src/services/vantage-mode-service.test.ts @@ -0,0 +1,63 @@ +import chai, { expect } from 'chai'; +import { describe } from 'mocha'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { ApiGeneralError } from '../errors/api-error'; +import { VantageModeRepository } from '../repositories/vantage-mode-repository'; +import { getMockDBConnection } from '../__mocks__/db'; +import { VantageModeService } from './vantage-mode-service'; + +chai.use(sinonChai); + +describe('VantageModeService', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('getVantageModesByMethodLookupIds', () => { + it('should run successfully and return vantage modes for the provided method lookup ids', async () => { + const mockVantageMode = { + vantage_mode_id: 1, + vantage_id: 101, + name: 'Mode A', + description: 'Description for Mode A' + }; + + sinon.stub(VantageModeRepository.prototype, 'getVantageModesByMethodLookupIds').resolves([mockVantageMode]); + + const dbConnection = getMockDBConnection(); + const service = new VantageModeService(dbConnection); + + const methodLookupIds = [1, 2, 3]; + const response = await service.getVantageModesByMethodLookupIds(methodLookupIds); + + expect(response).to.eql([mockVantageMode]); + }); + + it('should return an empty array when no vantage modes are found for the provided method lookup ids', async () => { + sinon.stub(VantageModeRepository.prototype, 'getVantageModesByMethodLookupIds').resolves([]); + + const dbConnection = getMockDBConnection(); + const service = new VantageModeService(dbConnection); + + const methodLookupIds = [10, 20, 30]; + const response = await service.getVantageModesByMethodLookupIds(methodLookupIds); + + expect(response).to.eql([]); + }); + + it('should handle errors gracefully when repository method fails', async () => { + sinon.stub(VantageModeRepository.prototype, 'getVantageModesByMethodLookupIds').rejects(new Error('Query error')); + + const dbConnection = getMockDBConnection(); + const service = new VantageModeService(dbConnection); + + try { + await service.getVantageModesByMethodLookupIds([1, 2, 3]); + expect.fail('Expected method to throw an error'); + } catch (actualError) { + expect((actualError as ApiGeneralError).message).to.equal('Query error'); + } + }); + }); +}); diff --git a/api/src/services/vantage-mode-service.ts b/api/src/services/vantage-mode-service.ts new file mode 100644 index 0000000000..c94ef5af76 --- /dev/null +++ b/api/src/services/vantage-mode-service.ts @@ -0,0 +1,31 @@ +import { IDBConnection } from '../database/db'; +import { VantageMode, VantageModeRepository } from '../repositories/vantage-mode-repository'; +import { DBService } from './db-service'; + +/** + * Service layer for vantage mode related information + * + * @export + * @class VantageModeService + * @extends {DBService} + */ +export class VantageModeService extends DBService { + VantageModeRepository: VantageModeRepository; + + constructor(connection: IDBConnection) { + super(connection); + + this.VantageModeRepository = new VantageModeRepository(connection); + } + + /** + * Get vantage modes for a set of method lookup ids + * + * @param {number[]} methodLookupIds + * @return {*} {Promise} + * @memberof VantageModeService + */ + async getVantageModesByMethodLookupIds(methodLookupIds: number[]): Promise { + return this.VantageModeRepository.getVantageModesByMethodLookupIds(methodLookupIds); + } +} diff --git a/api/src/utils/csv-utils/csv-config-utils.test.ts b/api/src/utils/csv-utils/csv-config-utils.test.ts new file mode 100644 index 0000000000..c5622b51e1 --- /dev/null +++ b/api/src/utils/csv-utils/csv-config-utils.test.ts @@ -0,0 +1,202 @@ +import { expect } from 'chai'; +import xlsx, { WorkSheet } from 'xlsx'; +import { CSVConfigUtils } from './csv-config-utils'; +import { CSVConfig } from './csv-config-validation.interface'; + +describe('CSVConfigUtils', () => { + describe('init', () => { + it('should initialize the CSVConfigUtils', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([ + { TEST: 'cellValue', ALIASED_HEADER: 'cellValue2', DYNAMIC_HEADER: 'dynamicValue' } + ]); + const mockConfig: CSVConfig = { + staticHeadersConfig: { + TEST: { aliases: [] }, + TEST_ALIAS: { aliases: ['ALIASED_HEADER'] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + expect(utils).to.be.instanceOf(CSVConfigUtils); + expect(utils._config).to.be.equal(mockConfig); + expect(utils.worksheet).to.be.equal(worksheet); + expect(utils.worksheetRows).to.be.deep.equal([ + { TEST: 'cellValue', ALIASED_HEADER: 'cellValue2', DYNAMIC_HEADER: 'dynamicValue' } + ]); + expect(utils.worksheetHeaders).to.be.deep.equal(['TEST', 'ALIASED_HEADER', 'DYNAMIC_HEADER']); + expect(utils.worksheetAliasedStaticHeaders).to.be.deep.equal(['TEST', 'ALIASED_HEADER']); + expect(utils.worksheetStaticHeaders).to.be.deep.equal(['TEST', 'TEST_ALIAS']); + expect(utils.worksheetDynamicHeaders).to.be.deep.equal(['DYNAMIC_HEADER']); + }); + }); + + describe('getCellValue', () => { + it('should get the cell value from a CSV row', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }]); + const mockConfig = { + staticHeadersConfig: { + TEST: { aliases: [] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const cellValue = utils.getCellValue('TEST', { TEST: 'cellValue' }); + + expect(cellValue).to.be.equal('cellValue'); + }); + + it('should return undefined if the header does not exist', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }]); + const mockConfig = { + staticHeadersConfig: { + TEST: { aliases: [] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const cellValue = utils.getCellValue('UNKNOWN' as any, { TEST: 'cellValue' }); + + expect(cellValue).to.be.equal(undefined); + }); + + it('should get the cell value by the alias', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ TEST_ALIAS: 'cellValue' }]); + const mockConfig: CSVConfig = { + staticHeadersConfig: { + TEST: { aliases: ['OTHER_ALIAS', 'TEST_ALIAS'] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const cellValue = utils.getCellValue('TEST', { TEST_ALIAS: 'cellValue' }); + + expect(cellValue).to.be.equal('cellValue'); + }); + + it('should return undefined for a bad header / alias', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ TEST_ALIAS: 'cellValue' }]); + const mockConfig: CSVConfig = { + staticHeadersConfig: { + TEST: { aliases: ['OTHER_ALIAS'] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const cellValue = utils.getCellValue('NOT_FOUND', { TEST_ALIAS: 'cellValue' }); + + expect(cellValue).to.be.equal(undefined); + }); + }); + + describe('getCellValues', () => { + it('should get the cell values from a CSV row', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }]); + const mockConfig = { + staticHeadersConfig: { + TEST: { aliases: [] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const cellValues = utils.getCellValues('TEST'); + + expect(cellValues).to.be.deep.equal(['cellValue']); + }); + + it('should get the cell values from a CSV row when using alias', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([ + { TEST_ALIAS: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' } + ]); + + const mockConfig: CSVConfig = { + staticHeadersConfig: { + TEST: { aliases: ['TEST_ALIAS'] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const cellValues = utils.getCellValues('TEST'); + + expect(cellValues).to.be.deep.equal(['cellValue']); + }); + }); + + describe('getUniqueCellValues', () => { + it('should get the unique cell values from a CSV row', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([ + { TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }, + { TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }, + { TEST: 'cellValue2', DYNAMIC_HEADER: 'dynamicValue' } + ]); + + const mockConfig = { + staticHeadersConfig: { + TEST: { aliases: [] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const cellValues = utils.getUniqueCellValues('TEST'); + + expect(cellValues).to.be.deep.equal(['cellValue', 'cellValue2']); + }); + }); + + describe('isCellUnique', () => { + it('should return true if the cell is unique', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([ + { TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }, + { TEST: 'cellValue2', DYNAMIC_HEADER: 'dynamicValue' } + ]); + + const mockConfig = { + staticHeadersConfig: { + TEST: { aliases: [] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const isUnique = utils.isCellUnique('TEST', 'cellValue'); + + expect(isUnique).to.be.true; + }); + + it('should return false if the cell is not unique', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([ + { TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }, + { TEST: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' } + ]); + + const mockConfig = { + staticHeadersConfig: { + TEST: { aliases: [] } + }, + ignoreDynamicHeaders: false + }; + + const utils = new CSVConfigUtils(worksheet, mockConfig); + + const isUnique = utils.isCellUnique('TEST', 'cellValue'); + + expect(isUnique).to.be.false; + }); + }); +}); diff --git a/api/src/utils/csv-utils/csv-config-utils.ts b/api/src/utils/csv-utils/csv-config-utils.ts new file mode 100644 index 0000000000..555a75af67 --- /dev/null +++ b/api/src/utils/csv-utils/csv-config-utils.ts @@ -0,0 +1,174 @@ +import { countBy, difference } from 'lodash'; +import { WorkSheet } from 'xlsx'; +import { getHeadersUpperCase, getWorksheetRowObjects } from '../xlsx-utils/worksheet-utils'; +import { CSVConfig, CSVRow } from './csv-config-validation.interface'; + +/** + * CSV Config Utils - A collection of methods useful when building CSVConfigs + * + * @exports + * @template StaticHeaderType - The static header type + * @class CSVConfigUtils + */ +export class CSVConfigUtils> { + _config: CSVConfig; + worksheet: WorkSheet; + worksheetRows: CSVRow[]; + + constructor(worksheet: WorkSheet, config: CSVConfig) { + this._config = config; + this.worksheet = worksheet; + this.worksheetRows = getWorksheetRowObjects(worksheet); + } + + /** + * The CSV config static headers. + * + * @returns {Uppercase[]} - The config headers + */ + get configStaticHeaders(): Uppercase[] { + return Object.keys(this._config.staticHeadersConfig) as Uppercase[]; + } + + /** + * The CSV worksheet headers. Raw incomming headers from the worksheet. + * + * @example + * worksheetHeaders: ['STATIC1', 'STATIC2_ALIAS', 'DYNAMIC1'] + * this: ['STATIC1', 'STATIC2_ALIAS', 'DYNAMIC1'] + * + * @returns {Uppercase[]} - The headers + */ + get worksheetHeaders(): Uppercase[] { + return getHeadersUpperCase(this.worksheet) as Uppercase[]; + } + + /** + * The CSV worksheet aliased static headers (leaves aliased headers as is). + * + * @example + * worksheetHeaders: ['STATIC1', 'STATIC2_ALIAS', 'DYNAMIC1'] + * this: ['STATIC1', 'STATIC2_ALIAS'] + * + * @returns {Uppercase[]} - The static headers + */ + get worksheetAliasedStaticHeaders(): Uppercase[] { + const staticHeaders: Uppercase[] = []; + const worksheetHeaders = new Set(this.worksheetHeaders); + + for (const header of this.configStaticHeaders) { + if (worksheetHeaders.has(header)) { + staticHeaders.push(header); + } + + const aliases = this._config.staticHeadersConfig[header].aliases; + + for (const alias of aliases) { + if (worksheetHeaders.has(alias)) { + // Pushing the alias instead of the static header + staticHeaders.push(alias); + } + } + } + + return staticHeaders; + } + + /** + * The CSV worksheet static headers (converts aliased headers to static headers). + * + * @example + * worksheetHeaders: ['STATIC1', 'STATIC2_ALIAS', 'DYNAMIC'] // STATIC2_ALIAS is an alias for STATIC2 + * this: ['STATIC1', 'STATIC2'] + * + * @returns {Uppercase[]} - The static headers + */ + get worksheetStaticHeaders(): Uppercase[] { + const staticHeaders: Uppercase[] = []; + const worksheetHeaders = new Set(this.worksheetHeaders); + + for (const header of this.configStaticHeaders) { + if (worksheetHeaders.has(header)) { + staticHeaders.push(header); + } + + const aliases = this._config.staticHeadersConfig[header].aliases; + + for (const alias of aliases) { + if (worksheetHeaders.has(alias)) { + // Pushing the static header instead of the alias + staticHeaders.push(header); + } + } + } + + return staticHeaders; + } + + /** + * The CSV worksheet dynamic headers. + * + * @example + * worksheetHeaders: ['STATIC1', 'STATIC2_ALIAS', 'DYNAMIC1'] + * this: ['DYNAMIC1'] + * + * @returns {Uppercase[]} - The dynamic headers + */ + get worksheetDynamicHeaders(): Uppercase[] { + return difference(this.worksheetHeaders, this.worksheetAliasedStaticHeaders); + } + + /** + * Get the cell value from a CSV row. + * + * @param {StaticHeaderType} header - The header name + * @param {CSVRow} row - The CSV row + * @returns {unknown} - The cell value + */ + getCellValue(header: StaticHeaderType, row: CSVRow) { + // Static header or dynamic header exact match + if (header in row) { + return row[header]; + } + + // Attempt to find the cell value from the header aliases + for (const alias of this._config.staticHeadersConfig[header]?.aliases ?? []) { + if (alias in row) { + return row[alias]; + } + } + } + + /** + * Get all the cell values from a static header. + * + * @param {StaticHeaderType} header - The header name + * @returns {unknown[]} - The cell values + */ + getCellValues(header: StaticHeaderType) { + return this.worksheetRows.map((row) => this.getCellValue(header, row)); + } + + /** + * Get all the unique cell values from a static header. + * + * @param {StaticHeaderType} header - The header name + * @returns {unknown[]} - The unique cell values + */ + getUniqueCellValues(header: StaticHeaderType) { + return [...new Set(this.getCellValues(header))]; + } + + /** + * Check if all the cell values from a static header are unique. + * + * @param {StaticHeaderType} header - The header name + * @param {unknown} cell - The cell value + * @returns {boolean} - Whether all the cell values are unique + */ + isCellUnique(header: StaticHeaderType, cell: unknown) { + const uniqueDictionary = countBy(this.getCellValues(header), (value) => String(value).toLowerCase()); + const dictionaryKey = String(cell).toLowerCase(); + return uniqueDictionary[dictionaryKey] === 1 || uniqueDictionary[dictionaryKey] === undefined; + } +} diff --git a/api/src/utils/csv-utils/csv-config-validation.interface.ts b/api/src/utils/csv-utils/csv-config-validation.interface.ts new file mode 100644 index 0000000000..07cad6ee57 --- /dev/null +++ b/api/src/utils/csv-utils/csv-config-validation.interface.ts @@ -0,0 +1,193 @@ +/** + * The CSV configuration interface + * + */ +export interface CSVConfig = Uppercase> { + /** + * Record containing the static headers, their aliases, and the `validateCell` and `setCellValue` callbacks + * to be called for each static cell. + * + * Note: A static header is a header that is known and defined in the configuration. + * + * @type {Record} + */ + staticHeadersConfig: Record; + /** + * Contains the `validateCell` and `setCellValue` callbacks to be called for each dynamic cell. + * + * Note: A dynamic header is a header that is not known and defined in the configuration. + * The actual header name is `dynamic` meaning it is defined by the user. + * + * ie: Additional headers like measurements, markings, collection units etc. + * + * @type {CSVHeaderConfig | undefined} + */ + dynamicHeadersConfig?: CSVHeaderConfig; + /** + * Boolean to ignore dynamic headers. + * + * ie: If true, the dynamic headers will not be processed. + * + * @type {boolean} + */ + ignoreDynamicHeaders: boolean; +} + +interface CSVStaticHeaderConfig { + /** + * A list of aliases for the header. + * + * @type {Uppercase[]} + */ + aliases: Uppercase[]; + /** + * Indicates if the header is optional. Set this to true if you want to be able to omit the header from the CSV. + * + * Note: This is not related to the cell validation. It is used to check if the header is present in the CSV. + * + * @type {true} + */ + optional?: true; +} + +/** + * The CSV header config cell validator function + * + * @param {CSVParams} params - The CSV parameters + * @returns {CSVError[]} - The list of CSV errors + */ +export type CSVCellValidator = (params: CSVParams) => CSVError[]; + +/** + * The CSV header config cell setter function + * + * @param {CSVParams} params - The CSV parameters + * @returns {*} {any} - The new cell value + */ +export type CSVCellSetter = (params: CSVParams) => any; + +/** + * The CSV header configuration interface + * + */ +export interface CSVHeaderConfig { + /** + * Callback to fire when validating the cell. Returns a list of CSVErrors. + * + * @type {CSVCellValidator | undefined} The cell validator function + */ + validateCell?: CSVCellValidator; + /** + * Callback to fire when setting the cell (after validation). Returns the new cell value. + * + * ie: Convert a string to a number, or find a the matching UUID for the cell value. + * + * @type {CSVCellSetter | undefined} The cell setter function + */ + setCellValue?: CSVCellSetter; +} + +/** + * The CSV parameters interface - passed to the cell validation/setter callbacks. + * + */ +export interface CSVParams { + /** + * The cell value. + * + * @type {unknown} + */ + cell: unknown; + /** + * The row header name. The initial row key. + * + * @type {string} + */ + header: string; + /** + * The data row object. + * + * @type {CSVRow} + */ + row: CSVRow; + /** + * The row index. + * + * Note: First data row index 0. + * + * @type {number} + */ + rowIndex: number; + /** + * The config static header name. The final row key. + * + * @type {string | undefined} + */ + staticHeader?: string; +} + +/** + * The CSV error interface + * + * @example + * { + * error: `Invalid collection unit`, // No need to include the header name / cell + * solution: `Use a valid collection unit`, // Solution includes the instructions to resolve + * values: ['unit1', 'unit2'], // Optional list of allowed values + * header: 'POPULATION_UNIT', + * cell: 'unit3', + * row: 1, // Header row index 0. First data row index 1 + * } + */ +export interface CSVError { + /** + * The error message. + * + * @type {string} + */ + error: string; + /** + * The solution message. + * + * @type {string} + */ + solution: string; + /** + * The list of allowed values if applicable. + * + * @type {(string[] | number[]) | undefined} + */ + values?: string[] | number[]; + /** + * The cell value. + * + * @type {unknown | undefined} + */ + cell?: unknown; + /** + * The header name. + * + * @type {string | undefined} + */ + header?: string; + /** + * The row index the error occurred. + * + * Note: Header row index 0. First data row index 1. + * + * @type {number} + */ + row?: number; +} + +/** + * The raw unvalidated CSV row + * + */ +export type CSVRow = Record, any>; + +/** + * The validated CSV row keyed by the static headers + * + */ +export type CSVRowValidated> = Record; diff --git a/api/src/utils/csv-utils/csv-config-validation.test.ts b/api/src/utils/csv-utils/csv-config-validation.test.ts new file mode 100644 index 0000000000..99e881955e --- /dev/null +++ b/api/src/utils/csv-utils/csv-config-validation.test.ts @@ -0,0 +1,410 @@ +import chai, { expect } from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import xlsx, { WorkSheet } from 'xlsx'; +import { + executeSetCellValue, + executeValidateCell, + forEachCSVCell, + validateCSVHeaders, + validateCSVWorksheet +} from './csv-config-validation'; +import { CSVConfig } from './csv-config-validation.interface'; +chai.use(sinonChai); + +describe('csv-config-validation', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('validateCSVWorksheet', () => { + it('should return rows when CSV is valid', () => { + const validateCellStub = sinon.stub().returns([]); + const setCellValueStub = sinon.stub().returns('newValue'); + + const validateDynamicCellStub = sinon.stub().returns([]); + const setCellValueDynamicStub = sinon.stub().returns('newDynamicValue'); + + const mockConfig: CSVConfig = { + staticHeadersConfig: { + ALIAS: { + aliases: ['ALIAS_2'], + validateCell: validateCellStub, + setCellValue: setCellValueStub + } + }, + dynamicHeadersConfig: { + validateCell: validateDynamicCellStub, + setCellValue: setCellValueDynamicStub + }, + ignoreDynamicHeaders: false + }; + + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([ + { ALIAS_2: 'value', DYNAMIC_HEADER: 'dynamicValue', OTHER_DYNAMIC_HEADER: 'otherDynamicValue' } + ]); + + const result = validateCSVWorksheet(worksheet, mockConfig); + + expect(validateCellStub).to.have.been.calledOnce; + expect(setCellValueStub).to.have.been.calledOnce; + + expect(validateDynamicCellStub).to.have.been.calledTwice; + expect(setCellValueDynamicStub).to.have.been.calledTwice; + + expect(result).to.deep.equal({ + errors: [], + rows: [ + { + ALIAS: 'newValue', + DYNAMIC_HEADER: 'newDynamicValue', + OTHER_DYNAMIC_HEADER: 'newDynamicValue' + } + ] + }); + }); + + it('should only call execute handlers when headers have no errors', () => { + const validateCellStub = sinon.stub().returns([]); + const setCellValueStub = sinon.stub().returns('newValue'); + + const mockConfig: CSVConfig = { + staticHeadersConfig: { + ALIAS: { + aliases: ['ALIAS_2'], + validateCell: validateCellStub, + setCellValue: setCellValueStub + } + }, + ignoreDynamicHeaders: true + }; + + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ BAD: 'value' }]); + + const result = validateCSVWorksheet(worksheet, mockConfig); + + expect(validateCellStub).to.have.been.not.calledOnce; + expect(setCellValueStub).to.have.been.not.calledOnce; + + expect(result).to.deep.equal({ + errors: [ + { + error: 'A required column is missing', + solution: `Add all required columns to the file.`, + header: 'ALIAS', + values: ['ALIAS', 'ALIAS_2'], + row: 0 + } + ], + rows: [] + }); + }); + }); + + describe('validateCSVHeaders', () => { + it('should return an empty array if the headers are valid', () => { + const mockConfig: CSVConfig = { staticHeadersConfig: { ALIAS: { aliases: [] } }, ignoreDynamicHeaders: true }; + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ ALIAS: 'value' }]); + + const result = validateCSVHeaders(worksheet, mockConfig); + + expect(result).to.deep.equal([]); + }); + + it('should return an error if the worksheet is empty', () => { + const mockConfig: CSVConfig = { staticHeadersConfig: { ALIAS: { aliases: [] } }, ignoreDynamicHeaders: true }; + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([]); + + const result = validateCSVHeaders(worksheet, mockConfig); + + expect(result).to.deep.equal([ + { + row: 0, + error: 'No columns in the file', + solution: 'Add column names. Did you accidentally include an empty first row above the columns?', + values: ['ALIAS'] + } + ]); + }); + + it('should return an error if CSV missing row data', () => { + const mockConfig: CSVConfig = { staticHeadersConfig: { ALIAS: { aliases: [] } }, ignoreDynamicHeaders: true }; + const worksheet: WorkSheet = { A1: { t: 's', v: 'ALIAS' }, '!ref': 'A1' }; + + const result = validateCSVHeaders(worksheet, mockConfig); + + expect(result).to.deep.equal([ + { + row: 1, + error: 'No rows in the file', + solution: 'Add rows. Did you accidentally import the wrong file?' + } + ]); + }); + + it('should return an error if the worksheet is missing a required header', () => { + const mockConfig: CSVConfig = { staticHeadersConfig: { ALIAS: { aliases: [] } }, ignoreDynamicHeaders: true }; + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ NOT_ALIAS: 'value' }]); + + const result = validateCSVHeaders(worksheet, mockConfig); + + expect(result).to.deep.equal([ + { + row: 0, + error: 'A required column is missing', + solution: `Add all required columns to the file.`, + header: 'ALIAS', + values: ['ALIAS'] + } + ]); + }); + + it('should NOT return an error if the worksheet is missing a optional header', () => { + const mockConfig: CSVConfig = { + staticHeadersConfig: { ALIAS: { aliases: [], optional: true } }, + ignoreDynamicHeaders: true + }; + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ NOT_ALIAS: 'value' }]); + + const result = validateCSVHeaders(worksheet, mockConfig); + + expect(result).to.deep.equal([]); + }); + + it('should return an error if the worksheet has an unknown header and dynamic headers are not ignored', () => { + const mockConfig: CSVConfig = { staticHeadersConfig: { ALIAS: { aliases: [] } }, ignoreDynamicHeaders: false }; + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ ALIAS: 'alias', UNKNOWN_HEADER: 'value' }]); + + const result = validateCSVHeaders(worksheet, mockConfig); + + expect(result).to.deep.equal([ + { + row: 0, + error: 'An unknown column is included in the file', + solution: `Remove extra columns from the file.`, + header: 'UNKNOWN_HEADER' + } + ]); + }); + }); + + describe('forEachCSVCell', () => { + it('should iterate over each cell in the worksheet', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ TEST: 'cellValue' }]); + + const validateCellStub = sinon.stub(); + const setCellValueStub = sinon.stub(); + + const config: CSVConfig = { + staticHeadersConfig: { + TEST: { + aliases: [], + validateCell: validateCellStub, + setCellValue: setCellValueStub + } + }, + ignoreDynamicHeaders: true + }; + + const callbackStub = sinon.stub(); + + forEachCSVCell(worksheet, config, callbackStub); + + expect(callbackStub).to.have.been.calledOnceWithExactly( + { + cell: 'cellValue', + header: 'TEST', + rowIndex: 0, + row: { TEST: 'cellValue' }, + staticHeader: 'TEST' + }, + { + validateCell: validateCellStub, + setCellValue: setCellValueStub + } + ); + }); + + it('should iterate over each cell in the worksheet when alias is used', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([{ TEST_ALIAS: 'cellValue' }]); + + const validateCellStub = sinon.stub(); + const setCellValueStub = sinon.stub(); + + const config: CSVConfig = { + staticHeadersConfig: { + TEST: { + aliases: ['TEST_ALIAS'], + validateCell: validateCellStub, + setCellValue: setCellValueStub + } + }, + ignoreDynamicHeaders: true + }; + + const callbackStub = sinon.stub(); + + forEachCSVCell(worksheet, config, callbackStub); + + expect(callbackStub).to.have.been.calledOnceWithExactly( + { + cell: 'cellValue', + header: 'TEST_ALIAS', + rowIndex: 0, + row: { TEST_ALIAS: 'cellValue' }, + staticHeader: 'TEST' + }, + { + validateCell: validateCellStub, + setCellValue: setCellValueStub + } + ); + }); + + it('should iterate over dynamic cell values', () => { + const worksheet: WorkSheet = xlsx.utils.json_to_sheet([ + { TEST_ALIAS: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' } + ]); + + const staticValidateCellStub = sinon.stub(); + const staticSetCellValueStub = sinon.stub(); + + const validateDynamicCellStub = sinon.stub(); + const setCellValueDynamicStub = sinon.stub(); + + const config: CSVConfig = { + staticHeadersConfig: { + TEST: { + aliases: ['TEST_ALIAS'], + validateCell: staticValidateCellStub, + setCellValue: staticSetCellValueStub + } + }, + dynamicHeadersConfig: { + validateCell: validateDynamicCellStub, + setCellValue: setCellValueDynamicStub + }, + ignoreDynamicHeaders: false + }; + + const callbackStub = sinon.stub(); + + forEachCSVCell(worksheet, config, callbackStub); + + expect(callbackStub).to.have.been.calledTwice; + + expect(callbackStub.getCall(0).args).to.deep.equal([ + { + cell: 'cellValue', + header: 'TEST_ALIAS', + rowIndex: 0, + row: { TEST_ALIAS: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }, + staticHeader: 'TEST' + }, + { + validateCell: staticValidateCellStub, + setCellValue: staticSetCellValueStub + } + ]); + + expect(callbackStub.getCall(1).args).to.deep.equal([ + { + cell: 'dynamicValue', + header: 'DYNAMIC_HEADER', + rowIndex: 0, + row: { TEST_ALIAS: 'cellValue', DYNAMIC_HEADER: 'dynamicValue' }, + staticHeader: undefined // Dynamic headers have no static header mapping + }, + { + validateCell: validateDynamicCellStub, + setCellValue: setCellValueDynamicStub + } + ]); + }); + }); + + describe('executeValidateCell', () => { + it('should call the validateCell callback and mutate errors array', () => { + const errors: any[] = []; + + const validateCellStub = sinon.stub().returns([{ error: 'error', solution: 'solution' }]); + + const params = { + cell: 'cellValue', + header: 'TEST', + rowIndex: 0, + row: { TEST: 'cellValue' }, + staticHeader: 'TEST' + }; + + const headerConfig = { + validateCell: validateCellStub + }; + + executeValidateCell(params, headerConfig, errors); + expect(validateCellStub).to.have.been.calledOnceWithExactly(params); + expect(errors).to.deep.equal([ + { + error: 'error', + solution: 'solution', + cell: 'cellValue', + header: 'TEST', + row: 1, + values: undefined + } + ]); + }); + }); + + describe('executeSetCellValue', () => { + it('should call the setCellValue callback and mutate the row', () => { + const row = { TEST: 'cellValue' }; + + const setCellValueStub = sinon.stub().returns('newValue'); + + const params = { + cell: 'cellValue', + header: 'TEST', + rowIndex: 0, + row, + staticHeader: 'TEST' + }; + + const headerConfig = { + setCellValue: setCellValueStub + }; + + const mutableRows = [row]; + + executeSetCellValue(params, headerConfig, mutableRows); + + expect(setCellValueStub).to.have.been.calledOnceWithExactly(params); + expect(mutableRows).to.deep.equal([{ TEST: 'newValue' }]); + }); + + it('should remap the key for a static header alias', () => { + const row = { TEST: 'cellValue' }; + + const setCellValueStub = sinon.stub().returns('newValue'); + + const params = { + cell: 'cellValue', + header: 'TEST', + rowIndex: 0, + row, + staticHeader: 'NEW_KEY' + }; + + const headerConfig = { + setCellValue: setCellValueStub + }; + + const mutableRows = [row]; + + executeSetCellValue(params, headerConfig, mutableRows); + + expect(setCellValueStub).to.have.been.calledOnceWithExactly(params); + expect(mutableRows).to.deep.equal([{ NEW_KEY: 'newValue' }]); + }); + }); +}); diff --git a/api/src/utils/csv-utils/csv-config-validation.ts b/api/src/utils/csv-utils/csv-config-validation.ts new file mode 100644 index 0000000000..8b5347b862 --- /dev/null +++ b/api/src/utils/csv-utils/csv-config-validation.ts @@ -0,0 +1,242 @@ +import { WorkSheet } from 'xlsx'; +import { getWorksheetRowObjects } from '../xlsx-utils/worksheet-utils'; +import { CSVConfigUtils } from './csv-config-utils'; +import { + CSVConfig, + CSVError, + CSVHeaderConfig, + CSVParams, + CSVRow, + CSVRowValidated +} from './csv-config-validation.interface'; + +/** + * Validate the CSV worksheet with the CSV config. + * + * @template StaticHeaderType - The CSV static headers + * @param {WorkSheet} worksheet - The worksheet + * @param {CSVConfigType} config - The CSV configuration + * @returns {*} {{ errors: CSVError[]; rows: CSVRowValidated[] }} - The CSV errors and rows + */ +export const validateCSVWorksheet = >( + worksheet: WorkSheet, + config: CSVConfig +): { errors: CSVError[]; rows: CSVRowValidated[] } => { + const rows: CSVRowValidated[] = []; + const errors = validateCSVHeaders(worksheet, config); + + // If there are errors in the headers, return early + if (errors.length) { + return { errors: errors, rows: [] }; + } + + // Iterate over each cell in the worksheet and validate + set cell values + forEachCSVCell(worksheet, config, (params, headerConfig) => { + // Validate the cell value and modify the errors + executeValidateCell(params, headerConfig, errors); // Mutates `errors` + + // If there are errors in the cell don't set the cell value + if (errors.length) { + return; + } + + // Set the cell value and modify the rows + executeSetCellValue(params, headerConfig, rows); // Mutates `rows` + }); + + if (errors.length) { + return { errors: errors, rows: [] }; + } + + return { errors: [], rows: rows }; +}; + +/** + * Validate the CSV static and dynamic headers against the CSV config. + * + * @param {WorkSheet} worksheet - The worksheet + * @param {CSVConfig} config - The CSV configuration + * @returns {*} {CSVError[]} - The CSV errors + */ +export const validateCSVHeaders = (worksheet: WorkSheet, config: CSVConfig): CSVError[] => { + const csvErrors: CSVError[] = []; + + const configUtils = new CSVConfigUtils(worksheet, config); + + if (!configUtils.worksheetHeaders.length) { + return [ + { + error: 'No columns in the file', + solution: 'Add column names. Did you accidentally include an empty first row above the columns?', + values: configUtils.configStaticHeaders, + row: 0 + } + ]; + } + + if (!configUtils.worksheetRows.length) { + return [ + { + error: 'No rows in the file', + solution: 'Add rows. Did you accidentally import the wrong file?', + row: 1 + } + ]; + } + + const worksheetStaticHeaders = new Set(configUtils.worksheetStaticHeaders); + + for (const staticHeader of configUtils.configStaticHeaders) { + const headerConfig = config.staticHeadersConfig[staticHeader]; + const worksheetHasStaticHeader = worksheetStaticHeaders.has(staticHeader); + + // Validate the CSV is not missing a required header + if (!headerConfig.optional && !worksheetHasStaticHeader) { + csvErrors.push({ + error: 'A required column is missing', + solution: `Add all required columns to the file.`, + header: staticHeader, + values: [staticHeader, ...config.staticHeadersConfig[staticHeader].aliases], + row: 0 + }); + } + } + + // Validate the CSV has no unknown headers (if dynamic headers not ignored or allowed) + if (!config.ignoreDynamicHeaders && !config.dynamicHeadersConfig && configUtils.worksheetDynamicHeaders.length) { + for (const unknownHeader of configUtils.worksheetDynamicHeaders) { + csvErrors.push({ + error: 'An unknown column is included in the file', + solution: `Remove extra columns from the file.`, + header: unknownHeader, + row: 0 + }); + } + } + + return csvErrors; +}; + +/** + * Iterate over each cell in the CSV worksheet. + * + * @param {WorkSheet} worksheet - The worksheet + * @param {CSVConfig} config - The CSV configuration + * @param {(params: CSVParams, headerConfig: CSVHeaderConfig) => void} callback - The callback function + * @returns {*} {void} + */ +export const forEachCSVCell = ( + worksheet: WorkSheet, + config: CSVConfig, + callback: (params: CSVParams, headerConfig: CSVHeaderConfig) => void +): void => { + const staticHeaderConfigMap = _getCSVStaticHeaderMap(config); + const worksheetRows = getWorksheetRowObjects(worksheet); + + for (let i = 0; i < worksheetRows.length; i++) { + const worksheetRow = worksheetRows[i]; + + for (const header in worksheetRow) { + // Get the header config for the cell (static or dynamic) + const headerConfig = staticHeaderConfigMap.get(header) ?? config.dynamicHeadersConfig ?? {}; + const cell = worksheetRow[header]; + const params: CSVParams = { + cell, + header, + row: worksheetRow, + rowIndex: i, + staticHeader: staticHeaderConfigMap.get(header)?.staticHeader + }; + + callback(params, { + validateCell: headerConfig.validateCell, + setCellValue: headerConfig.setCellValue + }); + } + } +}; + +/** + * Execute the CSVConfig `setCellValue` callback for the cell. + * + * Note: This mutates the CSV row objects `mutableRows`. + * + * @param {CSVParams} params - The CSV parameters + * @param {CSVHeaderConfig} headerConfig - The header configuration + * @param {CSVRow[]} mutableRows - The mutable rows array + * @returns {*} {CSVRow[]} - The updated row + */ +export const executeSetCellValue = (params: CSVParams, headerConfig: CSVHeaderConfig, mutableRows: CSVRow[]) => { + const headerKey = params.staticHeader?.toUpperCase() ?? params.header.toUpperCase(); + const cellValue = headerConfig?.setCellValue?.(params) ?? params.cell; + + // Remove the aliased header if it is not the static header + if (params.staticHeader && params.header !== params.staticHeader) { + delete params.row[params.header]; + } + + params.row[headerKey] = cellValue; + + mutableRows[params.rowIndex] = params.row; +}; + +/** + * Execute the CSVConfig `validateCell` callback for the cell. + * + * Note: This mutates the CSV errors array `mutableErrors`. + * + * @param {CSVParams} params - The CSV parameters + * @param {CSVHeaderConfig} headerConfig - The header configuration + * @param {CSVError[]} mutableErrors - The mutable errors array + * @returns {*} {void} + */ +export const executeValidateCell = ( + params: CSVParams, + headerConfig: CSVHeaderConfig, + mutableErrors: CSVError[] +): void => { + if (!headerConfig.validateCell) { + return; + } + + const cellErrors = headerConfig.validateCell(params); + + if (cellErrors.length) { + cellErrors.forEach((error) => { + mutableErrors.push({ + error: error.error, + solution: error.solution, + values: error.values, + cell: error.cell ?? params.cell, + header: error.header ?? params.header, + row: error.row ?? params.rowIndex + 1 // headers: 0, data row: 1 + }); + }); + } +}; + +/** + * Get the header config map for the CSV worksheet staticHeaders and aliases. + * + * Maps the header / alias name to the header config. + * + * @param {CSVConfig} config - The CSV configuration + * @returns {*} {Map} - The header config Map + */ +export const _getCSVStaticHeaderMap = (config: CSVConfig) => { + const headerMap = new Map(); + + for (const [staticHeader, headerConfig] of Object.entries(config.staticHeadersConfig)) { + for (const header of [staticHeader, ...headerConfig.aliases]) { + const uppercasedHeader = header.toUpperCase(); + + if (headerMap.has(uppercasedHeader)) { + throw new Error(`Duplicate header in CSV config: ${uppercasedHeader}`); + } + + headerMap.set(uppercasedHeader, { ...headerConfig, staticHeader }); + } + } + + return headerMap; +}; diff --git a/api/src/utils/csv-utils/csv-header-configs.test.ts b/api/src/utils/csv-utils/csv-header-configs.test.ts new file mode 100644 index 0000000000..109f9ebc37 --- /dev/null +++ b/api/src/utils/csv-utils/csv-header-configs.test.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import { z } from 'zod'; +import { getDescriptionCellValidator, getTsnCellValidator, validateZodCell } from './csv-header-configs'; + +describe('CSVHeaderConfigs', () => { + describe('validateZodCell', () => { + it('should return an empty array if the cell is valid', () => { + const result = validateZodCell({ cell: 123 } as any, z.number()); + expect(result).to.be.deep.equal([]); + }); + + it('should return an array of CSV error objects when invalid', () => { + const result = validateZodCell({ cell: 'hi', header: 'HEADER', rowIndex: 0 } as any, z.number().min(0).max(0)); + expect(result).to.be.deep.equal([ + { + error: 'Expected number, received string', + solution: 'Update the cell value to match the expected type' + } + ]); + }); + }); + + describe('getTsnCellValidator', () => { + it('should return an empty array if the cell is valid', () => { + const tsns = new Set([1, 2]); + const tsnValidator = getTsnCellValidator(tsns); + + const result = tsnValidator({ cell: 1, row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([]); + }); + + it('should return single error when cell value not included in TSNs', () => { + const tsns = new Set([1, 2]); + const tsnValidator = getTsnCellValidator(tsns); + + const result = tsnValidator({ cell: 3, row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([ + { + error: `Did not receive a Taxonomic Serial Number (TSN) for the species`, + solution: `Use a valid Taxonomic Serial Number (TSN) instead of a name to reference species.` + } + ]); + }); + }); + + describe('getDescriptionCellValidator', () => { + it('should return an empty array if the cell is valid', () => { + const descriptionValidator = getDescriptionCellValidator(); + + const result = descriptionValidator({ cell: 'description', row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result).to.be.deep.equal([]); + }); + + it('should return a single error when invalid', () => { + const badDescriptions = ['', 2, null, ' ']; + + for (const badDescription of badDescriptions) { + const descriptionValidator = getDescriptionCellValidator(); + + const result = descriptionValidator({ cell: badDescription, row: {}, header: 'HEADER', rowIndex: 0 }); + + expect(result.length).to.be.equal(1); + } + }); + }); +}); diff --git a/api/src/utils/csv-utils/csv-header-configs.ts b/api/src/utils/csv-utils/csv-header-configs.ts new file mode 100644 index 0000000000..a2f8c5bb5b --- /dev/null +++ b/api/src/utils/csv-utils/csv-header-configs.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { CSVCellValidator, CSVError, CSVParams } from './csv-config-validation.interface'; + +/** + * Utility function to validate a CSV cell using a Zod schema. + * + * @param {CSVParams} params - The cell parameters + * @param {z.ZodSchema} schema - The Zod schema + * @param {string} [solution] - The solution message + * @returns {*} {CSVError[]} - The cell validation errors + */ +export const validateZodCell = (params: CSVParams, schema: z.ZodSchema, solution?: string): CSVError[] => { + const errors: CSVError[] = []; + + const parsed = schema.safeParse(params.cell); + + if (!parsed.success) { + parsed.error.errors.forEach((error) => { + errors.push({ + error: error.message, + solution: solution ?? 'Update the cell value to match the expected type' + }); + }); + } + + return errors; +}; + +/** + * Get the TSN header cell validator. + * + * Rules: + * 1. The cell must be a number greater than or equal to 0 + * 2. The cell must be a real ITIS TSN (from the provided set) + * + * @param {Set} tsns Set of allowed ITIS TSNs + * @returns {*} {CSVCellValidator} The validate cell callback + */ +export const getTsnCellValidator = (tsns: Set): CSVCellValidator => { + return (params: CSVParams) => { + if (tsns.has(Number(params.cell))) { + return []; + } + + return [ + { + error: `Did not receive a Taxonomic Serial Number (TSN) for the species`, + solution: `Use a valid Taxonomic Serial Number (TSN) instead of a name to reference species.` + } + ]; + }; +}; + +/** + * Get the description header cell validator. + * + * Rules: + * 1. The cell must be a string or undefined with a maximum length of 250 + * + * @returns {*} {CSVCellValidator} The validate cell callback + */ +export const getDescriptionCellValidator = (): CSVCellValidator => { + return (params: CSVParams) => { + return validateZodCell(params, z.string().trim().min(1).max(250).optional()); + }; +}; diff --git a/api/src/utils/env-config.ts b/api/src/utils/env-config.ts new file mode 100644 index 0000000000..913afeedec --- /dev/null +++ b/api/src/utils/env-config.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import { getLogger } from './logger'; + +const defaultLog = getLogger('src/utils/env-config.ts'); + +const ZodEnvString = z.string().trim().min(1, { message: 'Required' }); // '' or ' ' are invalid +const ZodEnvNumber = z.coerce.number().min(1, { message: 'Required and must be a positive value.' }); // -1 is invalid + +// Schema for environment configuration +export const EnvSchema = z.object({ + // Environment + NODE_ENV: z.enum(['development', 'test', 'production']), + NODE_OPTIONS: ZodEnvString, + TZ: z.literal('America/Vancouver'), + + // API server + API_HOST: ZodEnvString, + API_PORT: ZodEnvNumber, + + // Database + DB_HOST: ZodEnvString, + DB_PORT: ZodEnvNumber, + DB_USER_API: ZodEnvString, + DB_USER_API_PASS: ZodEnvString, + DB_DATABASE: ZodEnvString, + + // Keycloak + KEYCLOAK_HOST: ZodEnvString, + KEYCLOAK_REALM: ZodEnvString, + KEYCLOAK_ADMIN_USERNAME: ZodEnvString, + KEYCLOAK_ADMIN_PASSWORD: ZodEnvString, + KEYCLOAK_API_TOKEN_URL: ZodEnvString, + KEYCLOAK_API_CLIENT_ID: ZodEnvString, + KEYCLOAK_API_CLIENT_SECRET: ZodEnvString, + KEYCLOAK_API_HOST: ZodEnvString, + KEYCLOAK_API_ENVIRONMENT: ZodEnvString, + + // Logging + LOG_LEVEL: z.enum(['silent', 'error', 'warn', 'info', 'debug', 'silly']), + LOG_LEVEL_FILE: z.enum(['silent', 'error', 'warn', 'info', 'debug', 'silly']), + LOG_FILE_DIR: ZodEnvString, + LOG_FILE_NAME: ZodEnvString, + LOG_FILE_DATE_PATTERN: ZodEnvString, + LOG_FILE_MAX_SIZE: ZodEnvString, + LOG_FILE_MAX_FILES: ZodEnvString, + + // Validation + API_RESPONSE_VALIDATION_ENABLED: z.enum(['true', 'false']), + DATABASE_RESPONSE_VALIDATION_ENABLED: z.enum(['true', 'false']), + + // File upload limits + MAX_REQ_BODY_SIZE: ZodEnvNumber, + MAX_UPLOAD_NUM_FILES: ZodEnvNumber, + MAX_UPLOAD_FILE_SIZE: ZodEnvNumber, + + // External Services + CB_API_HOST: ZodEnvString, + APP_HOST: ZodEnvString, + + // Biohub + BACKBONE_INTERNAL_API_HOST: ZodEnvString, + BACKBONE_INTAKE_PATH: ZodEnvString, + BACKBONE_ARTIFACT_INTAKE_PATH: ZodEnvString, + BIOHUB_TAXON_PATH: ZodEnvString, + BIOHUB_TAXON_TSN_PATH: ZodEnvString, + + // Object Store + OBJECT_STORE_URL: ZodEnvString, + OBJECT_STORE_ACCESS_KEY_ID: ZodEnvString, + OBJECT_STORE_SECRET_KEY_ID: ZodEnvString, + OBJECT_STORE_BUCKET_NAME: ZodEnvString, + S3_KEY_PREFIX: ZodEnvString, + + // GCNotify + GCNOTIFY_SECRET_API_KEY: ZodEnvString, + GCNOTIFY_ADMIN_EMAIL: ZodEnvString, + GCNOTIFY_ONBOARDING_REQUEST_EMAIL_TEMPLATE: z.string().uuid(), + GCNOTIFY_ONBOARDING_REQUEST_SMS_TEMPLATE: z.string().uuid(), + GCNOTIFY_REQUEST_RESUBMIT_TEMPLATE: z.string().uuid(), + GCNOTIFY_EMAIL_URL: ZodEnvString, + GCNOTIFY_SMS_URL: ZodEnvString, + + // ClamAV + CLAMAV_PORT: ZodEnvNumber, + CLAMAV_HOST: ZodEnvString, + ENABLE_FILE_VIRUS_SCAN: z.enum(['true', 'false']), + + // Extra + FEATURE_FLAGS: z.string().trim().optional() // flagA,flagB,flagC +}); + +type Env = z.infer; + +/** + * Load Environment Variables and validate them against the Zod schema. + * + * @returns {*} {Env} Validated environment variables + */ +export const loadEvironmentVariables = (): Env => { + const parsed = EnvSchema.safeParse(process.env); + + if (!parsed.success) { + defaultLog.error({ + label: 'loadEvironmentVariables', + message: 'Environment variables validation check failed', + errors: parsed.error.flatten().fieldErrors + }); + + process.exit(1); + } + + return parsed.data; +}; + +// Extend NodeJS ProcessEnv to include the EnvSchema +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface ProcessEnv extends Env {} + } +} diff --git a/api/src/utils/file-utils.test.ts b/api/src/utils/file-utils.test.ts index 6137d956db..48f53cf5c5 100644 --- a/api/src/utils/file-utils.test.ts +++ b/api/src/utils/file-utils.test.ts @@ -2,6 +2,7 @@ import { S3Client } from '@aws-sdk/client-s3'; import { expect } from 'chai'; import { describe } from 'mocha'; import { + bulkDeleteFilesFromS3, deleteFileFromS3, generateS3FileKey, getS3HostUrl, @@ -21,6 +22,14 @@ describe('deleteFileFromS3', () => { }); }); +describe('bulkDeleteFilesFromS3', () => { + it('returns null when no keys provided', async () => { + const result = await bulkDeleteFilesFromS3([]); + + expect(result).to.be.null; + }); +}); + describe('getS3SignedURL', () => { it('returns null when no key specified', async () => { const result = await getS3SignedURL(null as unknown as string); @@ -85,6 +94,36 @@ describe('generateS3FileKey', () => { expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/submissions/3/testFileName'); }); + + it('returns critter captures folder file path', async () => { + process.env.S3_KEY_PREFIX = 'some/s3/prefix'; + + const result = generateS3FileKey({ + projectId: 1, + surveyId: 2, + critterId: 3, + folder: 'captures', + critterbaseCaptureId: '123-456-789', + fileName: 'testFileName' + }); + + expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/critters/3/captures/123-456-789/testFileName'); + }); + + it('returns critter mortalities folder file path', async () => { + process.env.S3_KEY_PREFIX = 'some/s3/prefix'; + + const result = generateS3FileKey({ + projectId: 1, + surveyId: 2, + critterId: 3, + folder: 'mortalities', + critterbaseMortalityId: '123-456-789', + fileName: 'testFileName' + }); + + expect(result).to.equal('some/s3/prefix/projects/1/surveys/2/critters/3/mortalities/123-456-789/testFileName'); + }); }); describe('getS3HostUrl', () => { @@ -97,8 +136,7 @@ describe('getS3HostUrl', () => { }); it('should yield a default S3 host url', () => { - delete process.env.OBJECT_STORE_URL; - delete process.env.OBJECT_STORE_BUCKET_NAME; + Object.assign(process.env, { OBJECT_STORE_URL: undefined, OBJECT_STORE_BUCKET_NAME: undefined }); const result = getS3HostUrl(); @@ -154,7 +192,7 @@ describe('_getClamAvScanner', () => { it('should return a clamAv scanner client', () => { process.env.ENABLE_FILE_VIRUS_SCAN = 'true'; process.env.CLAMAV_HOST = 'host'; - process.env.CLAMAV_PORT = '1111'; + process.env.CLAMAV_PORT = 1111; const result = _getClamAvScanner(); expect(result).to.not.be.null; @@ -176,7 +214,7 @@ describe('_getObjectStoreBucketName', () => { }); it('should return its default value', () => { - delete process.env.OBJECT_STORE_BUCKET_NAME; + Object.assign(process.env, { OBJECT_STORE_BUCKET_NAME: undefined }); const result = _getObjectStoreBucketName(); expect(result).to.equal(''); @@ -212,7 +250,7 @@ describe('_getObjectStoreUrl', () => { }); it('should return its default value', () => { - delete process.env.OBJECT_STORE_URL; + Object.assign(process.env, { OBJECT_STORE_URL: undefined }); const result = _getObjectStoreUrl(); expect(result).to.equal('https://nrs.objectstore.gov.bc.ca'); @@ -234,7 +272,7 @@ describe('getS3KeyPrefix', () => { }); it('should return its default value', () => { - delete process.env.S3_KEY_PREFIX; + Object.assign(process.env, { S3_KEY_PREFIX: undefined }); const result = getS3KeyPrefix(); expect(result).to.equal('sims'); diff --git a/api/src/utils/file-utils.ts b/api/src/utils/file-utils.ts index da556e1e49..67356cfe3a 100644 --- a/api/src/utils/file-utils.ts +++ b/api/src/utils/file-utils.ts @@ -2,6 +2,8 @@ import { CompleteMultipartUploadCommandOutput, DeleteObjectCommand, DeleteObjectCommandOutput, + DeleteObjectsCommand, + DeleteObjectsCommandOutput, GetObjectCommand, GetObjectCommandOutput, HeadObjectCommand, @@ -29,7 +31,7 @@ export const _getClamAvScanner = async (): Promise => { return new NodeClam().init({ clamdscan: { host: process.env.CLAMAV_HOST, - port: Number(process.env.CLAMAV_PORT) + port: process.env.CLAMAV_PORT } }); }; @@ -121,6 +123,33 @@ export async function deleteFileFromS3(key: string): Promise} the response from S3 or null if required parameters are null + */ +export async function bulkDeleteFilesFromS3(keys: string[]): Promise { + const s3Client = _getS3Client(); + + if (!keys.length || !s3Client) { + return null; + } + + return s3Client.send( + new DeleteObjectsCommand({ + Bucket: _getObjectStoreBucketName(), + Delete: { + Objects: keys.map((key) => ({ Key: key })) + } + }) + ); +} + /** * Upload a file to S3. * @@ -295,7 +324,24 @@ export async function getS3SignedURLs(keys: string[]): Promise<(string | null)[] return Promise.all(keys.map((key) => getS3SignedURL(key))); } -export interface IS3FileKey { +type Projects3Key = { + /** + * The project ID the file is associated with. + */ + projectId: number; + /** + * The sub-folder where the file is stored. + * + * Note: For regular/generic file attachments, leave this undefined. + */ + folder?: 'reports' | 'telemetry-credentials'; + /** + * The name of the file. + */ + fileName: string; +}; + +type SurveyS3Key = { /** * The project ID the file is associated with. */ @@ -303,7 +349,7 @@ export interface IS3FileKey { /** * The survey ID the file is associated with. */ - surveyId?: number; + surveyId: number; /** * The template submission ID the file is associated with. * @@ -311,19 +357,72 @@ export interface IS3FileKey { */ submissionId?: number; /** - * The sub-folder in the project/survey where the file is stored. + * The sub-folder where the file is stored. * * Note: For regular/generic file attachments, leave this undefined. */ folder?: 'reports' | 'telemetry-credentials'; /** * The name of the file. - * - * @type {string} - * @memberof IS3FileKey */ fileName: string; -} +}; + +type CritterCaptureS3Key = { + /** + * The project ID the file is associated with. + */ + projectId: number; + /** + * The survey ID the file is associated with. + */ + surveyId: number; + /** + * The SIMS Critter ID the file is associated with. + */ + critterId: number; + /** + * The sub-folder where the file is stored. + */ + folder: 'captures'; + /** + * The Critterbase Capture ID (uuid) the file is associated with. + */ + critterbaseCaptureId: string; + /** + * The name of the file. + */ + fileName: string; +}; + +type CritterMortalityS3Key = { + /** + * The project ID the file is associated with. + */ + projectId: number; + /** + * The survey ID the file is associated with. + */ + surveyId: number; + /** + * The SIMS Critter ID the file is associated with. + */ + critterId: number; + /** + * The sub-folder where the file is stored. + */ + folder: 'mortalities'; + /** + * The Critterbase Mortality ID (uuid) the file is associated with. + */ + critterbaseMortalityId: string; + /** + * The name of the file. + */ + fileName: string; +}; + +export type IS3FileKey = Projects3Key | SurveyS3Key | CritterCaptureS3Key | CritterMortalityS3Key; /** * Generate an S3 key for a project or survey attachment file. @@ -340,20 +439,33 @@ export function generateS3FileKey(options: IS3FileKey): string { keyParts.push(options.projectId); } - if (options.surveyId) { + if ('surveyId' in options && options.surveyId) { keyParts.push('surveys'); keyParts.push(options.surveyId); } - if (options.submissionId) { + if ('submissionId' in options && options.submissionId) { keyParts.push('submissions'); keyParts.push(options.submissionId); } - if (options.folder) { + if ('critterId' in options && options.critterId) { + keyParts.push('critters'); + keyParts.push(options.critterId); + } + + if ('folder' in options && options.folder) { keyParts.push(options.folder); } + if ('critterbaseCaptureId' in options && options.critterbaseCaptureId) { + keyParts.push(options.critterbaseCaptureId); + } + + if ('critterbaseMortalityId' in options && options.critterbaseMortalityId) { + keyParts.push(options.critterbaseMortalityId); + } + if (options.fileName) { keyParts.push(options.fileName); } diff --git a/api/src/utils/logger.ts b/api/src/utils/logger.ts index d84219e57e..5b7c40e2d7 100644 --- a/api/src/utils/logger.ts +++ b/api/src/utils/logger.ts @@ -9,7 +9,9 @@ import DailyRotateFile from 'winston-daily-rotate-file'; const getLoggerTransportTypes = (): string[] => { const transportTypes = []; - if (process.env.npm_lifecycle_event !== 'test') { + // Do not output logs to file when running unit tests + // Note: Both lifecycle events are needed to prevent log files ie: `npm run test` or `npm run test-watch` + if (process.env.npm_lifecycle_event !== 'test' && process.env.npm_lifecycle_event !== 'test-watch') { transportTypes.push('file'); } diff --git a/api/src/utils/media/xlsx/xlsx-utils.ts b/api/src/utils/media/xlsx/xlsx-utils.ts index 2c3ad22eb9..72df06cfeb 100644 --- a/api/src/utils/media/xlsx/xlsx-utils.ts +++ b/api/src/utils/media/xlsx/xlsx-utils.ts @@ -1,5 +1,6 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import xlsx, { CellObject } from 'xlsx'; +import { DefaultDateFormat } from '../../../constants/dates'; import { safeTrim } from '../../string-utils'; /** @@ -106,7 +107,7 @@ export function replaceCellDates(cell: CellObject) { } if (isDateFormatCell(cell)) { - const DateFormat = 'YYYY-MM-DD'; + const DateFormat = DefaultDateFormat; cell.v = cellDate.format(DateFormat); return cell; } diff --git a/api/src/utils/nested-record.test.ts b/api/src/utils/nested-record.test.ts new file mode 100644 index 0000000000..c4f5c6599c --- /dev/null +++ b/api/src/utils/nested-record.test.ts @@ -0,0 +1,84 @@ +import { expect } from 'chai'; +import { NestedRecord } from './nested-record'; + +describe('NestedRecord', () => { + describe('constructor', () => { + it('should create a new instance of the class', () => { + const record = new NestedRecord(); + + expect(record).to.be.instanceof(NestedRecord); + expect(record).to.have.property('record').to.deep.equal({}); + }); + + it('should create a new instance of the class with a record', () => { + const record = new NestedRecord({ key: 'value' }); + + expect(record).to.be.instanceof(NestedRecord); + expect(record).to.have.property('record').to.deep.equal({ key: 'value' }); + }); + + it('should create a new instance of the class with a record with lowercase keys', () => { + const record = new NestedRecord({ a: { B: 'c' } }); + + expect(record).to.be.instanceof(NestedRecord); + expect(record) + .to.have.property('record') + .to.deep.equal({ a: { b: 'c' } }); + }); + + it('should create a new instance of the class with a record with lowercase keys and number keys', () => { + const record = new NestedRecord({ 1: { B: 'c' } }); + + expect(record).to.be.instanceof(NestedRecord); + expect(record) + .to.have.property('record') + .to.deep.equal({ 1: { b: 'c' } }); + }); + }); + + describe('get', () => { + it('should return a value from the record', () => { + const record = new NestedRecord({ a: { b: 'c' } }); + + expect(record.get('a', 'b')).to.equal('c'); + }); + + it('should return a value from the record case insensitive', () => { + const record = new NestedRecord({ a: { b: 'c' } }); + + expect(record.get('A', 'B')).to.equal('c'); + }); + + it('should return a value from the record case insensitive and number keys', () => { + const record = new NestedRecord({ a: { b: { 3: 'c' } } }); + + expect(record.get('A', 'B', 3)).to.equal('c'); + }); + }); + + describe('set', () => { + it('should set a value in the record', () => { + const record = new NestedRecord(); + + record.set({ path: ['a', 'b'], value: 'c' }); + + expect(record.record).to.deep.equal({ a: { b: 'c' } }); + }); + + it('should set a value in the record case insensitive', () => { + const record = new NestedRecord(); + + record.set({ path: ['A', 'B'], value: 'c' }); + + expect(record.record).to.deep.equal({ a: { b: 'c' } }); + }); + + it('should set a value in the record case insensitive and number keys', () => { + const record = new NestedRecord(); + + record.set({ path: ['A', 'B', 3], value: 'c' }); + + expect(record.record).to.deep.equal({ a: { b: { 3: 'c' } } }); + }); + }); +}); diff --git a/api/src/utils/nested-record.ts b/api/src/utils/nested-record.ts new file mode 100644 index 0000000000..39db132794 --- /dev/null +++ b/api/src/utils/nested-record.ts @@ -0,0 +1,97 @@ +import { get, setWith } from 'lodash'; + +type IKey = string | number; + +/** + * INestedRecord - A recursive nested record interface + * + */ +interface INestedRecord { + [key: IKey]: TValue | INestedRecord; +} + +/** + * NestedRecord - A class to handle nested records with case-insensitive keys + * + * @example + * const record = new NestedRecord({ a: { b: 'c' } }); + * record.get('A', 'B'); // 'c' + * record.has('A', 'B'); // true + * record.set({ path: ['A', 'B'], value: 'd' }); + * + * @class + * @exports + * @template TValue - The final value type + */ +export class NestedRecord { + record: INestedRecord; + + constructor(record?: INestedRecord) { + this.record = record ? this._convertRecordToLowerCase(record) : {}; + } + + /** + * Convert keys to lowercase + * + * @param {IKey[]} keys - The keys to convert + * @returns {IKey[]} The keys in lowercase + */ + _keysToLowercase(keys: IKey[]): IKey[] { + return keys.map((key) => key.toString().toLowerCase()); + } + + /** + * Convert a record to lowercase + * Note: This function is recursive + * + * @param {INestedRecord} record - The record to convert + * @returns {INestedRecord} The record with lowercase keys + */ + _convertRecordToLowerCase(record: INestedRecord): INestedRecord { + const newRecord: INestedRecord = {}; + + Object.keys(record).forEach((key) => { + const newKey = key.toLowerCase(); + + if (typeof record[key] === 'object') { + newRecord[newKey] = this._convertRecordToLowerCase(record[key] as INestedRecord); + } else { + newRecord[newKey] = record[key]; + } + }); + + return newRecord; + } + + /** + * Get a value from the nested record + * + * @param {...IKey[]} keys - The record keys in order + * @returns {*} {TValue | INestedRecord | undefined} The record or value or undefined + */ + get(...keys: IKey[]): INestedRecord | TValue | undefined { + return get(this.record, this._keysToLowercase(keys)); + } + + /** + * Check if the nested record has a value + * + * @param {...IKey[]} keys - The record keys in order + * @returns True if the value or record exists + */ + has(...keys: IKey[]): boolean { + return this.get(...this._keysToLowercase(keys)) !== undefined; + } + + /** + * Set a value in the nested record + * + * Note: First param is the value to set, all other params are the keys + * + * @param {{path: IKey[], value: TValue}} { path, value } - The new nested record value and path (record keys) + * @returns {*} {void} + */ + set({ path, value }: { path: IKey[]; value: TValue }): void { + setWith(this.record, this._keysToLowercase(path), value, Object); + } +} diff --git a/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts b/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts index 825a780682..9f2a49661a 100644 --- a/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts +++ b/api/src/utils/observation-xlsx-utils/environment-column-utils.test.ts @@ -191,7 +191,7 @@ describe('environment-column-utils', () => { { environment_qualitative_id: '22-123-456', environment_qualitative_option_id: '33-123-456', - name: 'Low', + name: 'Low'.toLowerCase(), description: 'Low' } ] @@ -207,7 +207,7 @@ describe('environment-column-utils', () => { { environment_qualitative_id: '44-123-456', environment_qualitative_option_id: '55-123-456', - name: 'North', + name: 'North'.toLowerCase(), description: 'North' } ] @@ -270,6 +270,7 @@ describe('environment-column-utils', () => { value: 100 } ]; + const environmentNameTypeDefinitionMap: EnvironmentNameTypeDefinitionMap = new Map([ [ 'Wind Speed', @@ -281,7 +282,8 @@ describe('environment-column-utils', () => { { environment_qualitative_id: '22-123-456', environment_qualitative_option_id: '33-123-456', - name: 'Low', + // Name is made lowercase at this point in the code + name: 'low', description: 'Low' } ] @@ -330,7 +332,8 @@ describe('environment-column-utils', () => { { environment_qualitative_id: '22-123-456', environment_qualitative_option_id: '33-123-456', - name: 'Low', + // Name is made lowercase at this point in the code + name: 'low', description: 'Low' } ] diff --git a/api/src/utils/observation-xlsx-utils/environment-column-utils.ts b/api/src/utils/observation-xlsx-utils/environment-column-utils.ts index cae6067f70..e4e6b14c5f 100644 --- a/api/src/utils/observation-xlsx-utils/environment-column-utils.ts +++ b/api/src/utils/observation-xlsx-utils/environment-column-utils.ts @@ -60,7 +60,13 @@ export function getEnvironmentColumnsTypeDefinitionMap( const qualitativeEnvironment = environmentTypeDefinitions.qualitative_environments.find( (item) => item.name.toLowerCase() === columnName.toLowerCase() ); + if (qualitativeEnvironment) { + // Lowercase the options for comparison + qualitativeEnvironment.options = qualitativeEnvironment.options.map((option) => ({ + ...option, + name: option.name.toLowerCase() + })); columnNameDefinitionMap.set(columnName, qualitativeEnvironment); continue; } @@ -104,7 +110,7 @@ export function validateEnvironments( if (isEnvironmentQualitativeTypeDefinition(environmentDefinition)) { return isQualitativeValueValid( - String(environmentToValidate.value), + String(environmentToValidate.value).toLowerCase(), environmentDefinition.options.map((option) => option.name) ); } diff --git a/api/src/utils/xlsx-utils/cell-utils.ts b/api/src/utils/xlsx-utils/cell-utils.ts index eb2e75f704..bfc8c91daa 100644 --- a/api/src/utils/xlsx-utils/cell-utils.ts +++ b/api/src/utils/xlsx-utils/cell-utils.ts @@ -1,5 +1,11 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import { CellObject } from 'xlsx'; +import { + AltDateFormat, + AltDateFormatReverse, + DefaultDateFormat, + DefaultDateFormatReverse +} from '../../constants/dates'; import { safeTrim } from '../string-utils'; /** @@ -25,8 +31,8 @@ export function trimCellWhitespace(cell: CellObject) { } /** - * Attempts to update the cells value with a formatted date or time value if the cell is a date type cell that has a - * date or time format. + * Attempts to identify and update cells whose values are either date strings or date objects to a consistent date + * format. * * @see https://docs.sheetjs.com/docs/csf/cell for details on cell fields * @export @@ -34,28 +40,38 @@ export function trimCellWhitespace(cell: CellObject) { * @return {*} */ export function replaceCellDates(cell: CellObject) { - if (!isDateCell(cell)) { + if (!cell.v) { + // Cell has no value return cell; } - const cellDate = dayjs(cell.v as any); + // If the cell was already interpreted as a date, format it to the default date format, and return + if (isDateCell(cell) && cell.v instanceof Date) { + // Attempt to parse the date using the format and update the cell value + cell.v = dayjs((cell.v as Date).toISOString(), DefaultDateFormat).format(DefaultDateFormat); + // Update the format to desired default format + cell.z = DefaultDateFormat; + // Ensure the cell type is set to date + cell.t = 'd'; - if (!cellDate.isValid()) { return cell; } - if (isDateFormatCell(cell)) { - const DateFormat = 'YYYY-MM-DD'; - cell.v = cellDate.format(DateFormat); - return cell; - } + // If the cell is a string cell with a valid date value, update the cell value to a date type cell using the default + // format, and return + const matchingStringDateFormat = isStringCellWithDateValue(cell); + if (matchingStringDateFormat) { + // Attempt to parse the date using the format and update the cell value + cell.v = dayjs(cell.v as string, matchingStringDateFormat).format(DefaultDateFormat); + // Update the format to desired default format + cell.z = DefaultDateFormat; + // Ensure the cell type is set to date + cell.t = 'd'; - if (isTimeFormatCell(cell)) { - const TimeFormat = 'HH:mm:ss'; - cell.v = cellDate.format(TimeFormat); return cell; } + // The cell neither a date type cell nor a string type cell with a valid date string value return cell; } @@ -81,6 +97,27 @@ export function isDateCell(cell: CellObject): boolean { return cell.t === 'd'; } +/** + * Checks if the cell value is a date string in a known date format. + * + * @export + * @param {CellObject} cell + * @return {*} {(false | string)} Return the matched date format if the cell value is a date string matching one known + * date format, return `false` otherwise. + */ +export function isStringCellWithDateValue(cell: CellObject): false | string { + if (!isStringCell(cell)) { + return false; + } + + const matchedFormats = [DefaultDateFormat, DefaultDateFormatReverse, AltDateFormat, AltDateFormatReverse].filter( + (format) => dayjs(String(cell.v), format).isValid() + ); + + // Ensure only one format matched + return matchedFormats.length === 1 ? matchedFormats[0] : false; +} + /** * Checks if the cell has a format, and if the format is likely a date format. * @@ -88,7 +125,7 @@ export function isDateCell(cell: CellObject): boolean { * @param {CellObject} cell * @return {*} {boolean} `true` if the cell has a date format, `false` otherwise. */ -export function isDateFormatCell(cell: CellObject): boolean { +export function doesCellHaveDateFormat(cell: CellObject): boolean { if (!cell.z) { return false; } @@ -104,7 +141,7 @@ export function isDateFormatCell(cell: CellObject): boolean { * @param {CellObject} cell * @return {*} {boolean} `true` if the cell has a time format, `false` otherwise. */ -export function isTimeFormatCell(cell: CellObject): boolean { +export function doesCellHaveTimeFormat(cell: CellObject): boolean { if (!cell.z) { // Not a date cell and/or has no date format return false; diff --git a/api/src/utils/xlsx-utils/column-aliases.ts b/api/src/utils/xlsx-utils/column-aliases.ts index 30baa70c35..48cf2a9a9b 100644 --- a/api/src/utils/xlsx-utils/column-aliases.ts +++ b/api/src/utils/xlsx-utils/column-aliases.ts @@ -5,5 +5,9 @@ export const CSV_COLUMN_ALIASES: Record, Uppercase[]> DESCRIPTION: ['COMMENT', 'COMMENTS', 'NOTES'], ALIAS: ['NICKNAME', 'ANIMAL'], MARKING_TYPE: ['TYPE'], - OBSERVATION_SUBCOUNT_SIGN: ['SIGN'] + OBSERVATION_SUBCOUNT_SIGN: ['SIGN'], + SAMPLING_SITE: ['SITE', 'SITE ID', 'LOCATION', 'SAMPLING SITE', 'STATION'], + SAMPLING_METHOD: ['METHOD', 'TECHNIQUE'], + SAMPLING_PERIOD: ['PERIOD', 'TIME PERIOD', 'SESSION'], + COMMENT: ['COMMENTS', 'NOTE', 'NOTES'] }; diff --git a/api/src/utils/xlsx-utils/worksheet-utils.test.ts b/api/src/utils/xlsx-utils/worksheet-utils.test.ts index 067e1c9063..45517f713e 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.test.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.test.ts @@ -16,7 +16,7 @@ const xlsxWorksheet: xlsx.WorkSheet = { H1: { t: 's', v: 'Wind Direction' }, A2: { t: 'n', w: '180703', v: 180703 }, B2: { t: 'n', w: '1', v: 1 }, - C2: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C2: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D2: { t: 's', v: '9:01' }, E2: { t: 'n', w: '-58', v: -58 }, F2: { t: 'n', w: '-123', v: -123 }, @@ -24,14 +24,14 @@ const xlsxWorksheet: xlsx.WorkSheet = { H2: { t: 's', v: 'North' }, A3: { t: 'n', w: '180596', v: 180596 }, B3: { t: 'n', w: '2', v: 2 }, - C3: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C3: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D3: { t: 's', v: '9:02' }, E3: { t: 'n', w: '-57', v: -57 }, F3: { t: 'n', w: '-122', v: -122 }, H3: { t: 's', v: 'North' }, A4: { t: 'n', w: '180713', v: 180713 }, B4: { t: 'n', w: '3', v: 3 }, - C4: { z: 'm/d/yy', t: 'd', v: '1970-01-01T08:00:00.000Z', w: '1/1/70' }, + C4: { t: 's', v: '1970-01-01T08:00:00.000Z' }, D4: { t: 's', v: '9:03' }, E4: { t: 'n', w: '-56', v: -56 }, F4: { t: 'n', w: '-121', v: -121 }, diff --git a/api/src/utils/xlsx-utils/worksheet-utils.ts b/api/src/utils/xlsx-utils/worksheet-utils.ts index e5d27a560c..38812641ae 100644 --- a/api/src/utils/xlsx-utils/worksheet-utils.ts +++ b/api/src/utils/xlsx-utils/worksheet-utils.ts @@ -1,4 +1,4 @@ -import { default as dayjs } from 'dayjs'; +import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import { intersection, isUndefined } from 'lodash'; import xlsx, { CellObject } from 'xlsx'; @@ -46,11 +46,16 @@ export interface IXLSXCSVValidator { * * @export * @param {MediaFile} file - * @param {xlsx.ParsingOptions} [options] * @return {*} {xlsx.WorkBook} */ -export const constructXLSXWorkbook = (file: MediaFile, options?: xlsx.ParsingOptions): xlsx.WorkBook => { - return xlsx.read(file.buffer, { cellDates: true, cellNF: true, cellHTML: false, ...options }); +export const constructXLSXWorkbook = (file: MediaFile): xlsx.WorkBook => { + return xlsx.read(file.buffer, { + cellDates: true, + cellNF: true, + cellHTML: false, + dateNF: '_', + raw: false + }); }; /** diff --git a/app/package-lock.json b/app/package-lock.json index f469247e16..4f54c67cea 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -49,6 +49,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^11.3.2", "react-leaflet": "^4.2.1", + "react-markdown": "^9.0.1", "react-number-format": "^4.5.2", "react-oidc-context": "^2.3.1", "react-router": "^5.3.3", @@ -95,6 +96,7 @@ "fs-constants": "^1.0.0", "fs-extra": "^11.1.1", "jest": "^29.7.0", + "jest-esm-transformer": "^1.0.0", "jest-sonar-reporter": "^2.0.0", "path-browserify": "^1.0.1", "prettier": "^2.8.8", @@ -5352,6 +5354,14 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", @@ -5375,8 +5385,15 @@ "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } }, "node_modules/@types/express": { "version": "4.17.21", @@ -5422,6 +5439,14 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/history": { "version": "4.7.11", "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", @@ -5571,6 +5596,14 @@ "@types/lodash": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5582,6 +5615,11 @@ "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, "node_modules/@types/node": { "version": "18.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz", @@ -5792,6 +5830,11 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, "node_modules/@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -6204,8 +6247,7 @@ "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, "node_modules/@webassemblyjs/ast": { "version": "1.12.1", @@ -7435,6 +7477,15 @@ "babel-plugin-transform-react-remove-prop-types": "^0.4.24" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -7879,6 +7930,15 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -7909,6 +7969,42 @@ "node": ">=10" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-types": { "version": "11.2.3", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.3.tgz", @@ -8113,6 +8209,15 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -9010,6 +9115,18 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -9135,7 +9252,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "engines": { "node": ">=6" } @@ -9196,6 +9312,18 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -10983,6 +11111,15 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", @@ -12412,6 +12549,44 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.2.tgz", + "integrity": "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -12586,6 +12761,15 @@ "node": ">=12" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-webpack-plugin": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz", @@ -12936,6 +13120,11 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==" + }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -12969,6 +13158,28 @@ "node": ">= 0.10" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -13133,6 +13344,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -13213,6 +13433,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -14636,6 +14865,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-esm-transformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jest-esm-transformer/-/jest-esm-transformer-1.0.0.tgz", + "integrity": "sha512-FoPgeMMwy1/CEsc8tBI41i83CEO3x85RJuZi5iAMmWoARXhfgk6Jd7y+4d+z+HCkTKNVDvSWKGRhwjzU9PUbrw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.4.4", + "@babel/plugin-transform-modules-commonjs": "^7.4.4" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -17388,6 +17627,15 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -17515,6 +17763,150 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.3.tgz", + "integrity": "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", + "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", @@ -17623,6 +18015,427 @@ "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==" }, + "node_modules/micromark": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", + "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.1.tgz", + "integrity": "sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", + "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", + "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", + "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", + "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", + "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", + "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", + "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", + "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", + "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", + "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", + "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", + "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", + "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", + "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.1.tgz", + "integrity": "sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", + "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", @@ -18860,6 +19673,30 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", + "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -20664,6 +21501,15 @@ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -21051,6 +21897,31 @@ "react-dom": "^18.0.0" } }, + "node_modules/react-markdown": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz", + "integrity": "sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-number-format": { "version": "4.9.4", "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-4.9.4.tgz", @@ -22737,6 +23608,37 @@ "node": ">= 0.10" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.1.tgz", + "integrity": "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -23695,6 +24597,15 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "dev": true }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -24076,6 +24987,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -24180,6 +25104,14 @@ "webpack": "^5.0.0" } }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/stylehacks": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", @@ -24924,6 +25856,15 @@ "node": ">=8" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -24932,6 +25873,15 @@ "node": ">=8" } }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/true-case-path": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-2.2.1.tgz", @@ -25237,6 +26187,35 @@ "node": ">=4" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unified/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-filename": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", @@ -25271,6 +26250,69 @@ "node": ">=8" } }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -25455,6 +26497,32 @@ "extsprintf": "^1.2.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -26464,6 +27532,15 @@ "engines": { "node": ">=10" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/app/package.json b/app/package.json index 4f524a4399..ac9f045166 100644 --- a/app/package.json +++ b/app/package.json @@ -65,6 +65,7 @@ "react-dom": "^18.2.0", "react-dropzone": "^11.3.2", "react-leaflet": "^4.2.1", + "react-markdown": "^9.0.1", "react-number-format": "^4.5.2", "react-oidc-context": "^2.3.1", "react-router": "^5.3.3", @@ -111,6 +112,7 @@ "fs-constants": "^1.0.0", "fs-extra": "^11.1.1", "jest": "^29.7.0", + "jest-esm-transformer": "^1.0.0", "jest-sonar-reporter": "^2.0.0", "path-browserify": "^1.0.1", "prettier": "^2.8.8", diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 5e09060113..954446e2eb 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -2,7 +2,7 @@ import { AuthenticatedRouteGuard, SystemRoleRouteGuard } from 'components/securi import { SYSTEM_ROLE } from 'constants/roles'; import { CodesContextProvider } from 'contexts/codesContext'; import { DialogContextProvider } from 'contexts/dialogContext'; -import AdminUsersRouter from 'features/admin/AdminUsersRouter'; +import AdminRouter from 'features/admin/AdminRouter'; import FundingSourcesRouter from 'features/funding-sources/FundingSourcesRouter'; import ProjectsRouter from 'features/projects/ProjectsRouter'; import ResourcesPage from 'features/resources/ResourcesPage'; @@ -78,13 +78,13 @@ const AppRouter: React.FC = () => { - + - + diff --git a/app/src/components/alert/AlertBar.tsx b/app/src/components/alert/AlertBar.tsx index ff7d46472f..fff13a5ff2 100644 --- a/app/src/components/alert/AlertBar.tsx +++ b/app/src/components/alert/AlertBar.tsx @@ -1,33 +1,40 @@ -import Alert from '@mui/material/Alert'; +import Alert, { AlertProps } from '@mui/material/Alert'; import AlertTitle from '@mui/material/AlertTitle'; -import Box from '@mui/material/Box'; -import React from 'react'; -interface IAlertBarProps { +interface IAlertBarProps extends AlertProps { severity: 'error' | 'warning' | 'info' | 'success'; variant: 'filled' | 'outlined' | 'standard'; title: string; text: string | JSX.Element; } -const AlertBar: React.FC = (props) => { - const { severity, variant, title, text } = props; +/** + * Returns an alert banner + * + * @param props {IAlertBarProps} + * @returns + */ +const AlertBar = (props: IAlertBarProps) => { + const { severity, variant, title, text, ...alertProps } = props; + + const defaultProps = { + severity: 'success', + variant: 'standard', + title: '', + text: '' + }; return ( - - - {title} - {text} - - + + {title} + {text} + ); }; -AlertBar.defaultProps = { - severity: 'success', - variant: 'standard', - title: '', - text: '' -}; - export default AlertBar; diff --git a/app/src/components/attachments/AttachmentTableDropzone.tsx b/app/src/components/attachments/AttachmentTableDropzone.tsx new file mode 100644 index 0000000000..4a4b67782e --- /dev/null +++ b/app/src/components/attachments/AttachmentTableDropzone.tsx @@ -0,0 +1,148 @@ +import { GridColDef } from '@mui/x-data-grid'; +import AttachmentsListItemMenuButton from 'components/attachments/list/AttachmentsListItemMenuButton'; +import { GenericFileNameColDef, GenericFileSizeColDef } from 'components/data-grid/GenericGridColumnDefinitions'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import FileUpload from 'components/file-upload/FileUpload'; +import { UploadFileStatus } from 'components/file-upload/FileUploadItem'; +import { PublishStatus } from 'constants/attachments'; + +interface IAttachment { + /** + * Attachment ID. + * + * @type {number} + */ + id: number; + /** + * S3 key for the attachment. + * + * @type {string} + */ + s3Key: string; + /** + * Attachment file name. + * + * @type {string} + */ + name: string; + /** + * Attachment file size in bytes. + * + * @type {number} + */ + size: number; + /** + * Attachment file type. + * + * @type {string} + * @example 'Other', 'Report', 'Capture', 'Mortality', 'Cfg' + */ + type: string; +} + +interface IAttachmentTableDropzoneProps { + /** + * List of uploaded attachments to display in the table. + * + * @type {IAttachment[] | undefined} + */ + attachments?: IAttachment[]; + /** + * Callback to download an attachment. + * + * @param {(id: number, attachmentType: string) => void} + */ + onDownloadAttachment: (id: number, attachmentType: string) => void; + /** + * Callback when a file is staged for upload. + * + * @param {(file: File | null) => void} + */ + onStagedAttachment: (file: File | null) => void; + /** + * Callback when a staged attachment is removed. + * + * @param {(fileName: string) => void} + */ + onRemoveStagedAttachment: (fileName: string) => void; + /** + * Callback when a previously uploaded attachment is removed. + * + * Note: Previously uploaded attachments exist in S3 and referenced in the database. + * + * @param {(id: number) => void} + */ + onRemoveUploadedAttachment: (id: number) => void; +} + +/** + * AttachmentTableDropzone + * + * @description Renders a dropzone for staged attachements and a table of uploaded attachments. + * + * @param {IAnimalAttachmentsProps} props + * @returns {*} + */ +export const AttachmentTableDropzone = (props: IAttachmentTableDropzoneProps): JSX.Element => { + const attachmentsListColumnDefs: GridColDef[] = [ + GenericFileNameColDef({ + field: 'name', + headerName: 'Name', + onClick: (params) => props.onDownloadAttachment(params.row.id, params.row.type) + }), + { + field: 'type', + flex: 1, + headerName: 'Type', + disableColumnMenu: true + }, + GenericFileSizeColDef({ field: 'size', headerName: 'Size' }), + { + field: 'actions', + headerName: '', + type: 'actions', + width: 70, + sortable: false, + disableColumnMenu: true, + resizable: false, + renderCell: (params) => { + return ( + props.onDownloadAttachment(params.row.id, params.row.type)} + onDeleteFile={() => props.onRemoveUploadedAttachment(params.row.id)} + onViewDetails={() => undefined} + /> + ); + } + } + ]; + return ( + <> + + + {props.attachments && props.attachments.length > 0 && ( + + noRowsMessage={'No Uploaded Attachments'} + columns={attachmentsListColumnDefs} + rows={props.attachments ?? []} + pageSizeOptions={[5, 10, 20]} + rowCount={props.attachments.length} + disableRowSelectionOnClick + initialState={{ + pagination: { + paginationModel: { + pageSize: 5 + } + } + }} + /> + )} + + ); +}; diff --git a/app/src/components/attachments/list/AttachmentsList.tsx b/app/src/components/attachments/list/AttachmentsList.tsx index 197fe494b1..d1e89d66db 100644 --- a/app/src/components/attachments/list/AttachmentsList.tsx +++ b/app/src/components/attachments/list/AttachmentsList.tsx @@ -107,6 +107,7 @@ const AttachmentsList = columns={attachmentsListColumnDefs} rows={attachments} pageSizeOptions={pageSizeOptions} + disableRowSelectionOnClick initialState={{ pagination: { paginationModel: { diff --git a/app/src/components/buttons/BreadcrumbNavButton.tsx b/app/src/components/buttons/BreadcrumbNavButton.tsx new file mode 100644 index 0000000000..ba5df84178 --- /dev/null +++ b/app/src/components/buttons/BreadcrumbNavButton.tsx @@ -0,0 +1,69 @@ +import { mdiChevronDown } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import Button from '@mui/material/Button'; +import grey from '@mui/material/colors/grey'; +import Menu, { MenuProps } from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import { PropsWithChildren, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; + +interface IBreadcrumbNavButtonProps { + menuItems: { label: string; to: string; icon?: string }[]; +} + +/** + * Returns a button that opens a menu of router links when clicked + * + * @param {PropsWithChildren} props + * @returns {*} + */ +export const BreadcrumbNavButton = (props: PropsWithChildren) => { + const { menuItems, children } = props; + + // State for managing the menu + const [anchorEl, setAnchorEl] = useState(null); + + // Handle menu opening + const handleMenuClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + // Handle menu closing + const handleMenuClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + {menuItems.map((item) => ( + { + handleMenuClose(); + }}> + {item.icon && } + {item.label} + + ))} + + + + + ); +}; diff --git a/app/src/components/buttons/HelpButtonDialog.tsx b/app/src/components/buttons/HelpButtonDialog.tsx new file mode 100644 index 0000000000..3104f045e0 --- /dev/null +++ b/app/src/components/buttons/HelpButtonDialog.tsx @@ -0,0 +1,55 @@ +import { mdiHelpCircleOutline } from '@mdi/js'; +import { Icon } from '@mdi/react'; +import { Button } from '@mui/material'; +import { CustomMarkdown } from 'components/markdown/CustomMarkdown'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext } from 'hooks/useContext'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; +import { PropsWithChildren } from 'react'; + +interface IHelpButtonDialogProps { + markdownType: MarkdownTypeNameEnum; +} + +/** + * Returns a button that opens a dialog containing markdown, allowing the user to score the markdown text if they haven't scored it yet. + * + * @param {PropsWithChildren} props + * @returns {*} + */ +const HelpButtonDialog = (props: PropsWithChildren) => { + const { markdownType, children } = props; + + const dialogContext = useDialogContext(); + const biohubApi = useBiohubApi(); + + const createDialogConfig = (markdown: any) => ({ + open: true, + dialogContent: , + hasSubmitted: markdown.participated, + onSubmit: async (score: number) => { + await biohubApi.markdown.insertScore({ markdownId: markdown.markdown_id, score }); + dialogContext.setScoreDialog({ hasSubmitted: true }); + }, + onOk: () => { + dialogContext.setScoreDialog({ open: false }); + } + }); + + // Open the markdown dialog + const handleOpenDialog = async () => { + const { markdown } = await biohubApi.markdown.getMarkdown({ typeName: markdownType }); + + if (markdown) { + dialogContext.setScoreDialog(createDialogConfig(markdown)); + } + }; + + return ( + + ); +}; + +export default HelpButtonDialog; diff --git a/app/src/components/buttons/MarkdownScoreButtons.tsx b/app/src/components/buttons/MarkdownScoreButtons.tsx new file mode 100644 index 0000000000..ac31f90c8e --- /dev/null +++ b/app/src/components/buttons/MarkdownScoreButtons.tsx @@ -0,0 +1,31 @@ +import { mdiThumbDownOutline, mdiThumbUpOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; + +interface IMarkdownScoreButtonsProps { + positiveText: string; + negativeText: string; + handleSubmit: (score: number) => void; +} + +/** + * Returns buttons to up-score or down-score + * + * @param {IMarkdownScoreButtonsProps} props + * @returns + */ +export const MarkdownScoreButtons = (props: IMarkdownScoreButtonsProps) => { + const { positiveText, negativeText, handleSubmit } = props; + + return ( + + + + + ); +}; diff --git a/app/src/components/chips/ColouredRectangleChip.tsx b/app/src/components/chips/ColouredRectangleChip.tsx index 9baf0282f0..e6922e596a 100644 --- a/app/src/components/chips/ColouredRectangleChip.tsx +++ b/app/src/components/chips/ColouredRectangleChip.tsx @@ -1,9 +1,10 @@ import { Color } from '@mui/material'; import Chip, { ChipProps } from '@mui/material/Chip'; +import { ReactElement } from 'react'; export interface IColouredRectangleChipProps extends ChipProps { colour: Color; - label: string | JSX.Element; + label: string | ReactElement; } /** @@ -26,7 +27,9 @@ const ColouredRectangleChip = (props: IColouredRectangleChipProps) => { fontWeight: 700, fontSize: '0.75rem', p: 1, - textTransform: 'uppercase' + textTransform: 'uppercase', + overflow: 'hidden', + textOverflow: 'ellipsis' }, ...props.sx }} diff --git a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx index f432551087..81317f0279 100644 --- a/app/src/components/data-grid/GenericGridColumnDefinitions.tsx +++ b/app/src/components/data-grid/GenericGridColumnDefinitions.tsx @@ -1,11 +1,17 @@ +import { mdiCommentOutline, mdiCommentText, mdiFileOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Link, Stack } from '@mui/material'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; -import { GridCellParams, GridColDef, GridValidRowModel } from '@mui/x-data-grid'; +import { GridCellParams, GridColDef, GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; import TimePickerDataGrid from 'components/data-grid/TimePickerDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; import { round } from 'lodash-es'; -import { getFormattedDate } from 'utils/Utils'; +import appTheme from 'themes/appTheme'; +import { getFormattedDate, getFormattedFileSize } from 'utils/Utils'; export const GenericDateColDef = (props: { field: string; @@ -18,7 +24,7 @@ export const GenericDateColDef = (props: { return { field, headerName, - description: description ?? undefined, + description: description, editable: true, hideable: true, type: 'date', @@ -71,7 +77,7 @@ export const GenericTimeColDef = (props: { headerName, editable: true, hideable: true, - description: description ?? undefined, + description: description, type: 'string', width: 150, disableColumnMenu: true, @@ -133,7 +139,7 @@ export const GenericLatitudeColDef = (props: { return { field, headerName, - description: description ?? undefined, + description: description, editable: true, hideable: true, width: 120, @@ -194,7 +200,7 @@ export const GenericLongitudeColDef = (props: { return { field, headerName, - description: description ?? undefined, + description: description, editable: true, hideable: true, width: 120, @@ -243,3 +249,113 @@ export const GenericLongitudeColDef = (props: { } }; }; + +export const GenericCommentColDef = (props: { + field: string; + headerName: string; + description?: string; + hasError: (params: GridCellParams) => boolean; + handleClose: () => void; + handleOpen: (params: GridRenderEditCellParams) => void; +}): GridColDef => { + const { field, headerName, description } = props; + + return { + field, + headerName, + description: description, + width: 75, + disableColumnMenu: true, + editable: true, + align: 'center', + renderEditCell: (params) => { + return ( + + + { + props.handleOpen(params); + }}> + {params.value ? ( + // The key prop is necessary for the color to correctly change when a value is set + + ) : ( + + )} + + + + ); + }, + renderCell: (params) => { + return ( + + + + {params.value ? ( + + ) : ( + + )} + + + + ); + } + }; +}; + +export const GenericFileNameColDef = (props: { + field: string; + headerName: string; + onClick?: (params: GridCellParams) => void; +}): GridColDef => { + return { + field: props.field, + headerName: props.headerName, + flex: 1, + disableColumnMenu: true, + renderCell: (params) => { + return ( + + + props.onClick?.(params)} tabIndex={0}> + {params.value} + + + ); + } + }; +}; + +export const GenericFileSizeColDef = (props: { + field: string; + headerName: string; +}): GridColDef => { + return { + field: props.field, + headerName: props.headerName, + flex: 1, + disableColumnMenu: true, + valueGetter: (params) => { + return getFormattedFileSize(params.value); + } + }; +}; diff --git a/app/src/components/data-grid/StyledDataGrid.tsx b/app/src/components/data-grid/StyledDataGrid.tsx index 566f929745..c7fddb4e62 100644 --- a/app/src/components/data-grid/StyledDataGrid.tsx +++ b/app/src/components/data-grid/StyledDataGrid.tsx @@ -43,12 +43,31 @@ export const StyledDataGrid = (props: StyledD borderBottom: 'none' } }, - '& .MuiDataGrid-columnHeader:first-of-type, .MuiDataGrid-cell:first-of-type': { - pl: 2 + // Define custom header padding for the first column vs every other column + '& .MuiDataGrid-columnHeader:first-of-type:not(.MuiDataGrid-columnHeaderCheckbox)': { + pl: 3 // Add extra padding to the first header, unless it is a checkbox header }, - '& .MuiDataGrid-columnHeader:last-of-type, .MuiDataGrid-cell:last-of-type': { - pr: 2 + '& .MuiDataGrid-columnHeader:first-of-type.MuiDataGrid-columnHeaderCheckbox': { + pl: 2 // Add extra padding to the first header when it is a checkbox header }, + '& .MuiDataGrid-columnHeader:not(:first-of-type)': { + pl: 1 // Add extra padding to every other header + }, + // Define custom cell padding for the first column vs every other column + '& .MuiDataGrid-cell:first-of-type:not(.MuiDataGrid-cellCheckbox)': { + pl: 3 // Add extra padding to the first cell, unless it is a checkbox cell + }, + '& .MuiDataGrid-cell:first-of-type.MuiDataGrid-cellCheckbox': { + pl: 2 // Add extra padding to the first cell when it is a checkbox cell + }, + '& .MuiDataGrid-cell:not(:first-of-type)': { + pl: 1 // Add extra padding to every other cell + }, + // Ensure the draggable container is at least 50px wide + '& .MuiDataGrid-columnHeaderDraggableContainer': { + minWidth: '50px' + }, + // Custom styling for cell content at different densities '&.MuiDataGrid-root--densityCompact .MuiDataGrid-cell': { py: '8px', wordWrap: 'anywhere' @@ -61,9 +80,6 @@ export const StyledDataGrid = (props: StyledD py: '22px', wordWrap: 'anywhere' }, - '& .MuiDataGrid-columnHeaderDraggableContainer': { - minWidth: '50px' - }, ...props.sx }} /> diff --git a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx index 91a244b3b9..afd145f91f 100644 --- a/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell.tsx @@ -28,6 +28,12 @@ export interface IAsyncAutocompleteDataGridEditCell< * @memberof IAsyncAutocompleteDataGridEditCell */ getCurrentOption: (value: ValueType) => Promise; + /** + * Initial options to display in the autocomplete, before the user types anything. + * + * @memberof IAsyncAutocompleteDataGridEditCell + */ + getInitialOptions?: () => AutocompleteOptionType[]; /** * Search function that returns an array of options to choose from. * @@ -46,6 +52,17 @@ export interface IAsyncAutocompleteDataGridEditCell< * Optional function to render the autocomplete option. */ renderOption?: AutocompleteProps['renderOption']; + /** + * Optional callback fired when an option is selected. + */ + onSelectOption?: (selectedOption: AutocompleteOptionType | null) => void; + /** + * Placeholder text for the input field. + * + * @type {string} + * @memberof IAsyncAutocompleteDataGridEditCell + */ + placeholder?: string; } /** @@ -64,7 +81,16 @@ const AsyncAutocompleteDataGridEditCell = < >( props: IAsyncAutocompleteDataGridEditCell ) => { - const { dataGridProps, getCurrentOption, getOptions, error, renderOption } = props; + const { + dataGridProps, + getCurrentOption, + getOptions, + getInitialOptions, + error, + renderOption, + onSelectOption, + placeholder + } = props; const ref = useRef(); @@ -80,14 +106,21 @@ const AsyncAutocompleteDataGridEditCell = < const [inputValue, setInputValue] = useState(''); // The currently selected option const [currentOption, setCurrentOption] = useState(null); + // Reference to disable search (used when selecting an option to prevent a redundant search) + const isSearchDisabled = useRef(false); // The array of options to choose from - const [options, setOptions] = useState([]); + const [options, setOptions] = useState(getInitialOptions?.() ?? []); // Is control loading (search in progress) const [isLoading, setIsLoading] = useState(false); useEffect(() => { let mounted = true; + if (isSearchDisabled.current) { + // Search is disabled + return; + } + if (!dataGridValue) { // No current value return; @@ -123,14 +156,20 @@ const AsyncAutocompleteDataGridEditCell = < useEffect(() => { let mounted = true; + if (isSearchDisabled.current) { + // Search is disabled + return; + } + if (inputValue === '') { - // No input value, nothing to search with - setOptions(currentOption ? [currentOption] : []); + // No search term, do not initiate search, cancel any existing search + setIsLoading(false); return; } - // Call async search function setIsLoading(true); + + // Call async search function getOptions(inputValue, (searchResults) => { if (!mounted) { return; @@ -166,8 +205,13 @@ const AsyncAutocompleteDataGridEditCell = < }} filterOptions={(item) => item} onChange={(_, selectedOption) => { - setOptions(selectedOption ? [selectedOption, ...options] : options); + // Disable search when selecting an option, to prevent a redundant search when the input field is updated + // with the user's selection + isSearchDisabled.current = true; + setCurrentOption(selectedOption); + onSelectOption?.(selectedOption); + setIsLoading(false); // Set the data grid cell value with selected options value dataGridProps.api.setEditCellValue({ @@ -176,7 +220,13 @@ const AsyncAutocompleteDataGridEditCell = < value: selectedOption?.value }); }} - onInputChange={(_, newInputValue) => { + onInputChange={(_, newInputValue, reason) => { + if (reason === 'clear' || reason === 'input') { + // Enable search when the user interacts with the input field + // A 'reset' event is created when the user selects an option, which should not trigger a search + isSearchDisabled.current = false; + } + setInputValue(newInputValue); }} renderInput={(params) => ( @@ -187,6 +237,7 @@ const AsyncAutocompleteDataGridEditCell = < variant="outlined" fullWidth error={error} + placeholder={placeholder} InputProps={{ color: error ? 'error' : undefined, ...params.InputProps, diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx index fadaca2fe0..55b1029a6b 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell.tsx @@ -16,14 +16,14 @@ export interface IConditionalAutocompleteDataGridEditCellProps< */ dataGridProps: GridRenderCellParams; /** - * + * All possible options for the autocomplete control. * * @type {OptionsType[]} * @memberof IConditionalAutocompleteDataGridEditCellProps */ allOptions: OptionsType[]; /** - * + * Given a row and list of all possible options, return the matching options for the autocomplete control. * * @memberof IConditionalAutocompleteDataGridEditCellProps */ diff --git a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx index 0fda933e5f..611e8e9c21 100644 --- a/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx +++ b/app/src/components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell.tsx @@ -16,14 +16,14 @@ export interface IConditionalAutocompleteDataGridViewCellProps< */ dataGridProps: GridRenderCellParams; /** - * + * All possible options for the autocomplete control. * * @type {OptionsType[]} * @memberof IConditionalAutocompleteDataGridViewCellProps */ allOptions: OptionsType[]; /** - * + * Given a row and list of all possible options, return the matching options for the autocomplete control. * * @memberof IConditionalAutocompleteDataGridViewCellProps */ diff --git a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx index ba47cfd44f..c439286ddb 100644 --- a/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx +++ b/app/src/components/data-grid/taxonomy/TaxonomyDataGridEditCell.tsx @@ -93,6 +93,7 @@ const TaxonomyDataGridEditCell = ( dataGridProps={dataGridProps} getCurrentOption={getCurrentOption} getOptions={getOptions} + placeholder="Search for a taxon" error={props.error} renderOption={(renderProps, renderOption) => ( void; + /** + * Callback fired if the 'Ok' button is clicked. + * + * @memberof IScoreDialogProps + */ + onOk: () => Promise | void; + + /** + * Indicates whether the user has already submitted before, in which case they cannot submit again + * + * @memberof IScoreDialogProps + */ + hasSubmitted?: boolean; + + /** + * Callback fired if the user scores dialog content + * + * @memberof IScoreDialogProps + */ + onSubmit?: (score: number) => Promise | void; + + /** + * The ok button label. + * + * @type {string} + * @memberof IScoreDialogProps + */ + okButtonLabel?: string; + + /** + * The no button label. + * + * @type {string} + * @memberof IScoreDialogProps + */ + noButtonLabel?: string; + + /** + * Optional ok-button props + * + * @type {any} + * Needed fix: Add correct hardcoded type. + * Note: LoadingButtonProps causes build compile issue + * https://github.com/mui/material-ui/issues/30038 + */ + okButtonProps?: any; + + /** + * Optional no-button props + * + * @type {any} + * Needed fix: Add correct hardcoded type. + * Note: LoadingButtonProps causes build compile issue + * https://github.com/mui/material-ui/issues/30038 + */ + noButtonProps?: any; + + /** + * Optional Boolean to state if button should be loading + * + * @type {boolean} + * @memberof IScoreDialogProps + */ + isLoading?: boolean; +} + +/** + * A dialog for displaying content and letting the user upscore or downscore to content. + * + * @param {*} props + * @return {*} + */ +const ScoreDialog = (props: IScoreDialogProps) => { + if (!props.open) { + return <>; + } + + return ( + + {props.dialogTitle && {props.dialogTitle}} + + {props.dialogText && {props.dialogText}} + {props.dialogContent} + + + {props.onSubmit && ( + + {props.hasSubmitted ? ( + Thanks for your feedback! + ) : ( + + )} + + )} + + {props.okButtonLabel ? props.okButtonLabel : 'Ok'} + + + + ); +}; + +export default ScoreDialog; diff --git a/app/src/components/fields/AutocompleteField.tsx b/app/src/components/fields/AutocompleteField.tsx index 421c969c23..5e39736b3b 100644 --- a/app/src/components/fields/AutocompleteField.tsx +++ b/app/src/components/fields/AutocompleteField.tsx @@ -25,6 +25,7 @@ export interface IAutocompleteField { required?: boolean; filterLimit?: number; showValue?: boolean; + disableClearable?: boolean; optionFilter?: 'value' | 'label'; // used to filter existing/ set data for the AutocompleteField, defaults to value in getExistingValue function getOptionDisabled?: (option: IAutocompleteFieldOption) => boolean; onChange?: (event: SyntheticEvent, option: IAutocompleteFieldOption | null) => void; @@ -67,6 +68,7 @@ const AutocompleteField = (props: IAutocompleteField< value={getExistingValue(get(values, props.name))} options={props.options} getOptionLabel={(option) => option.label} + disableClearable={props.disableClearable} isOptionEqualToValue={handleGetOptionSelected} getOptionDisabled={props.getOptionDisabled} filterOptions={createFilterOptions({ limit: props.filterLimit })} diff --git a/app/src/components/fields/DateField.tsx b/app/src/components/fields/DateField.tsx index 83ff4f36e0..fd1a4b5f69 100644 --- a/app/src/components/fields/DateField.tsx +++ b/app/src/components/fields/DateField.tsx @@ -4,25 +4,19 @@ import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DATE_FORMAT, DATE_LIMIT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; -import { FormikContextType } from 'formik'; +import { useFormikContext } from 'formik'; import { get } from 'lodash-es'; -interface IDateFieldProps { +interface IDateFieldProps { label: string; name: string; id: string; required: boolean; - formikProps: FormikContextType; } -export const DateField = (props: IDateFieldProps) => { - const { - formikProps: { values, errors, touched, setFieldValue }, - label, - name, - id, - required - } = props; +export const DateField = (props: IDateFieldProps) => { + const { values, errors, touched, setFieldValue, setFieldError } = useFormikContext(); + const { label, name, id, required } = props; const rawDateValue = get(values, name); const formattedDateValue = @@ -34,12 +28,6 @@ export const DateField = (props: IDateFieldProps }} @@ -49,7 +37,7 @@ export const DateField = (props: IDateFieldProps(props: IDateFieldProps { - if (!value || value === 'Invalid Date') { + if (!value || !dayjs(value).isValid()) { // The creation input value will be 'Invalid Date' when the date field is cleared (empty), and will // contain an actual date string value if the field is not empty but is invalid. setFieldValue(name, null); @@ -74,6 +62,7 @@ export const DateField = (props: IDateFieldProps diff --git a/app/src/components/fields/TimeField.tsx b/app/src/components/fields/TimeField.tsx index 23772bbfab..045f02a945 100644 --- a/app/src/components/fields/TimeField.tsx +++ b/app/src/components/fields/TimeField.tsx @@ -5,25 +5,19 @@ import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { TIME_FORMAT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; -import { FormikContextType } from 'formik'; +import { useFormikContext } from 'formik'; import get from 'lodash-es/get'; -interface ITimeFieldProps { +interface ITimeFieldProps { label: string; name: string; id: string; required: boolean; - formikProps: FormikContextType; } -export const TimeField = (props: ITimeFieldProps) => { - const { - formikProps: { values, errors, touched, setFieldValue }, - label, - name, - id, - required - } = props; +export const TimeField = (props: ITimeFieldProps) => { + const { values, errors, touched, setFieldValue } = useFormikContext(); + const { label, name, id, required } = props; const rawTimeValue = get(values, name); const formattedTimeValue = diff --git a/app/src/components/file-upload/FileUpload.tsx b/app/src/components/file-upload/FileUpload.tsx index 50d5507747..eca639a33c 100644 --- a/app/src/components/file-upload/FileUpload.tsx +++ b/app/src/components/file-upload/FileUpload.tsx @@ -19,10 +19,6 @@ export interface IUploadFile { error?: string; } -export interface IUploadFileListProps { - files: IUploadFile[]; -} - export type IReplaceHandler = () => void; export interface IFileUploadProps { diff --git a/app/src/components/layout/Header.test.tsx b/app/src/components/layout/Header.test.tsx index 1f6af9a314..c9d5e5e9e5 100644 --- a/app/src/components/layout/Header.test.tsx +++ b/app/src/components/layout/Header.test.tsx @@ -21,7 +21,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); @@ -40,7 +40,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); @@ -59,7 +59,7 @@ describe('Header', () => { ); expect(getByText('Projects')).toBeVisible(); - expect(getByText('Manage Users')).toBeVisible(); + expect(getByText('Admin')).toBeVisible(); expect(getByText('Standards')).toBeVisible(); }); diff --git a/app/src/components/layout/Header.tsx b/app/src/components/layout/Header.tsx index 1c87ee0caa..953ec35810 100644 --- a/app/src/components/layout/Header.tsx +++ b/app/src/components/layout/Header.tsx @@ -266,8 +266,8 @@ const Header: React.FC = () => { - - Manage Users + + Admin { - - Manage Users + + Admin diff --git a/app/src/components/markdown/CustomMarkdown.tsx b/app/src/components/markdown/CustomMarkdown.tsx new file mode 100644 index 0000000000..3abde6a74b --- /dev/null +++ b/app/src/components/markdown/CustomMarkdown.tsx @@ -0,0 +1,41 @@ +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Markdown from 'react-markdown'; +import appTheme from 'themes/appTheme'; + +interface CustomMarkdownProps { + markdown: string; +} + +/** + * This file must be mocked if tested with Jest because it is ESM only and Jest does not support ESM. + * + * Example code to include in test files that import this component: + * + * ``` + * jest.mock('../../../components/markdown/CustomMarkdown', () => { + * return {}; + * }); + * ``` + * See SurveyHeader.test.tsx for an example + * + * @param param + * @returns + */ +export const CustomMarkdown = ({ markdown }: CustomMarkdownProps) => { + const sx = { + '& h1': { ...appTheme.typography.h1 }, + '& h2': { ...appTheme.typography.h2 }, + '& h3': { ...appTheme.typography.h3, my: 0 }, + '& h4': { ...appTheme.typography.h4, my: 0 }, + '& h5': { ...appTheme.typography.h5, mb: 2 }, + '& h6': { ...appTheme.typography.h6, fontSize: '1rem', mb: 0, mt: 2, color: grey[700] }, + '& p, & li': { color: appTheme.palette.text.secondary, my: 1, fontSize: '0.95rem' } + }; + + return ( + + {markdown} + + ); +}; diff --git a/app/src/components/overlay/NoDataOverlay.tsx b/app/src/components/overlay/NoDataOverlay.tsx index cfb5ef4cce..f61111ca29 100644 --- a/app/src/components/overlay/NoDataOverlay.tsx +++ b/app/src/components/overlay/NoDataOverlay.tsx @@ -17,7 +17,7 @@ interface INoDataOverlayProps extends BoxProps { export const NoDataOverlay = (props: INoDataOverlayProps) => { const { title, subtitle, icon } = props; return ( - + {title} {icon && } diff --git a/app/src/components/species/ecological-units/EcologicalUnitDualSelect.tsx b/app/src/components/species/ecological-units/EcologicalUnitDualSelect.tsx new file mode 100644 index 0000000000..5f371c8c74 --- /dev/null +++ b/app/src/components/species/ecological-units/EcologicalUnitDualSelect.tsx @@ -0,0 +1,186 @@ +import { mdiClose } from '@mdi/js'; +import Icon from '@mdi/react'; +import Card from '@mui/material/Card'; +import grey from '@mui/material/colors/grey'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import AutocompleteField from 'components/fields/AutocompleteField'; +import { useFormikContext } from 'formik'; +import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { ICollectionCategory } from 'interfaces/useCritterApi.interface'; +import { get } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; + +const DEFAULT_ECOLOGICAL_UNIT_LABEL = 'Ecological Unit Option'; + +interface IEcologicalUnitSelectProps { + /** + * The ecological categories to display in the category field. + * @type {ICollectionCategory[]} + */ + ecologicalCategories: ICollectionCategory[]; + /** + * The category ids to filter from the autocomplete options. Excludes the current category. + * + * Note: Useful if needing to render multiple `EcologicalUnitDualSelect` components + * while preventing duplicate categories from being selected. + * + * @type {string[]} + */ + filterCategoryIds?: string[]; + /** + * The unit ids to filter from the autocomplete options. Excludes the current unit. + * + * Note: Useful if needing to render multiple `EcologicalUnitDualSelect` components + * while preventing duplicate units from being selected. + * + * @type {string[]} + */ + filterUnitIds?: string[]; + /** + * The formik field name for the category. + * @type {string} + */ + formikCategoryFieldName: string; + /** + * The formik field name for the unit. + * @type {string} + */ + formikUnitFieldName: string; + + /** + * Callback for when the delete button is clicked. + * @type {() => void} + */ + onDelete: () => void; +} + +/** + * Returns a pair of autocomplete fields for selecting an ecological category and unit (option). + * + * @param props {IEcologicalUnitsSelectProps} + * @returns + */ +export const EcologicalUnitDualSelect = (props: IEcologicalUnitSelectProps) => { + const formik = useFormikContext(); + const critterbaseApi = useCritterbaseApi(); + + // State for the ecological unit label - displays the currently selected category name + const [ecologicalUnitLabel, setEcologicalUnitLabel] = useState(DEFAULT_ECOLOGICAL_UNIT_LABEL); + + const ecologicalUnitOptionsLoader = useDataLoader((categoryId: string) => + critterbaseApi.xref.getCollectionUnits(categoryId) + ); + + // Get the current ecological unit / category id from the formik values + const ecologicalUnitId: string | null = get(formik.values, props.formikUnitFieldName); + const ecologicalCategoryId: string | null = get(formik.values, props.formikCategoryFieldName); + + // Filter out categories that are already selected (if included in filterCategoryIds) + const filteredEcologicalCategories = useMemo(() => { + const filterCategoryIdsSet = new Set(props.filterCategoryIds ?? []); + + return props.ecologicalCategories + .map((category) => ({ + value: category.collection_category_id, + label: category.category_name + })) + .filter( + (category) => + (ecologicalCategoryId && filterCategoryIdsSet.has(ecologicalCategoryId)) || + !filterCategoryIdsSet.has(category.value) + ); + }, [ecologicalCategoryId, props.ecologicalCategories, props.filterCategoryIds]); + + // Filter out units that are already selected (if included in filterUnitIds) + const filteredEcologicalUnits = useMemo(() => { + // Only show options if a category is selected and there are options to show + if (!ecologicalCategoryId || !ecologicalUnitOptionsLoader.data?.length) { + return []; + } + + const filterUnitIdsSet = new Set(props.filterUnitIds ?? []); + + return ( + ecologicalUnitOptionsLoader.data + .map((unit) => ({ + value: unit.collection_unit_id, + label: unit.unit_name + })) + // Filter out units that are already selected (if included in filterUnitIds) + .filter( + (unit) => + (ecologicalUnitId && !filterUnitIdsSet.has(ecologicalUnitId)) || + !filterUnitIdsSet.has(unit.value) || + unit.value === ecologicalUnitId + ) + ); + }, [ecologicalCategoryId, ecologicalUnitId, ecologicalUnitOptionsLoader.data, props.filterUnitIds]); + + useEffect(() => { + if (!ecologicalCategoryId) { + return; + } + // Refresh the ecological unit options when the selected category changes + ecologicalUnitOptionsLoader.refresh(ecologicalCategoryId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ecologicalCategoryId]); + + return ( + + { + // Clear the unit field if no category is selected + if (!option) { + formik.setFieldValue(props.formikUnitFieldName, undefined); + setEcologicalUnitLabel(DEFAULT_ECOLOGICAL_UNIT_LABEL); + return; + } + + // Set the category field value + formik.setFieldValue(props.formikCategoryFieldName, option.value); + // Set the ecological unit label + setEcologicalUnitLabel(option.label); + }} + required + disabled={Boolean(!filteredEcologicalCategories.length)} + sx={{ flex: '1 1 auto' }} + /> + + { + formik.setFieldValue(props.formikUnitFieldName, option?.value ?? undefined); + }} + sx={{ + flex: '1 1 auto' + }} + /> + + + + + + ); +}; diff --git a/app/src/components/species/ecological-units/EcologicalUnitsOptionSelect.tsx b/app/src/components/species/ecological-units/EcologicalUnitsOptionSelect.tsx deleted file mode 100644 index 45c3b6b546..0000000000 --- a/app/src/components/species/ecological-units/EcologicalUnitsOptionSelect.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; -import { useFormikContext } from 'formik'; - -interface IEcologicalUnitsOptionSelectProps { - /** - * Formik field name - * - * @type {string} - * @memberof IEcologicalUnitsOptionSelectProps - */ - name: string; - /** - * The label to display for the select field. - * - * @type {string} - * @memberof IEcologicalUnitsOptionSelectProps - */ - label: string; - /** - * List of options to display in the select field. - * - * @type {IAutocompleteFieldOption[]} - * @memberof IEcologicalUnitsOptionSelectProps - */ - options: IAutocompleteFieldOption[]; -} - -/** - * Returns a component for selecting ecological (ie. collection) unit options for a given ecological unit. - * - * @param {IEcologicalUnitsOptionSelectProps} props - * @return {*} - */ -export const EcologicalUnitsOptionSelect = (props: IEcologicalUnitsOptionSelectProps) => { - const { label, options, name } = props; - - const { setFieldValue } = useFormikContext(); - - return ( - { - if (option?.value) { - setFieldValue(name, option.value); - } - }} - disabled={Boolean(!options.length)} - required - sx={{ - flex: '1 1 auto' - }} - /> - ); -}; diff --git a/app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx b/app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx deleted file mode 100644 index d0ae926a47..0000000000 --- a/app/src/components/species/ecological-units/EcologicalUnitsSelect.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { mdiClose } from '@mdi/js'; -import Icon from '@mdi/react'; -import Card from '@mui/material/Card'; -import grey from '@mui/material/colors/grey'; -import IconButton from '@mui/material/IconButton'; -import Stack from '@mui/material/Stack'; -import AutocompleteField from 'components/fields/AutocompleteField'; -import { FieldArrayRenderProps, useFormikContext } from 'formik'; -import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { ICollectionCategory } from 'interfaces/useCritterApi.interface'; -import { useEffect, useMemo } from 'react'; -import { EcologicalUnitsOptionSelect } from './EcologicalUnitsOptionSelect'; - -interface EcologicalUnitsSelectProps { - categoryFieldName: string; - unitFieldName: string; - ecologicalUnits: ICollectionCategory[]; - selectedCategoryIds: string[]; - arrayHelpers: FieldArrayRenderProps; - index: number; -} - -/** - * Returns a pair of autocomplete fields for selecting an ecological unit category and value for the category. - * - * @param props {IEcologicalUnitsSelectProps} - * @returns - */ -export const EcologicalUnitsSelect = (props: EcologicalUnitsSelectProps) => { - const { index, ecologicalUnits, arrayHelpers, categoryFieldName, unitFieldName, selectedCategoryIds } = props; - const { setFieldValue } = useFormikContext(); - const critterbaseApi = useCritterbaseApi(); - - const ecologicalUnitOptionsLoader = useDataLoader((categoryId: string) => - critterbaseApi.xref.getCollectionUnits(categoryId) - ); - - const selectedCategoryId = selectedCategoryIds[index]; - - useEffect(() => { - if (selectedCategoryId) { - ecologicalUnitOptionsLoader.refresh(selectedCategoryId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedCategoryId]); - - // Memoized label for the selected ecological unit - const selectedCategoryLabel = useMemo(() => { - return ecologicalUnits.find((unit) => unit.collection_category_id === selectedCategoryId)?.category_name ?? ''; - }, [ecologicalUnits, selectedCategoryId]); - - // Filter out already selected categories - const availableCategories = useMemo(() => { - return ecologicalUnits - .filter( - (unit) => - !selectedCategoryIds.some( - (existingId) => existingId === unit.collection_category_id && existingId !== selectedCategoryId - ) - ) - .map((unit) => ({ - value: unit.collection_category_id, - label: unit.category_name - })); - }, [ecologicalUnits, selectedCategoryIds, selectedCategoryId]); - - const ecologicalUnitOptions = useMemo( - () => - ecologicalUnitOptionsLoader.data?.map((unit) => ({ - value: unit.collection_unit_id, - label: unit.unit_name - })) ?? [], - [ecologicalUnitOptionsLoader.data] - ); - - return ( - - { - if (option?.value) { - setFieldValue(categoryFieldName, option.value); - } - }} - required - sx={{ flex: '1 1 auto' }} - /> - - arrayHelpers.remove(index)} - sx={{ mt: 1.125 }}> - - - - ); -}; diff --git a/app/src/components/toolbar/CustomToggleButtonGroup.tsx b/app/src/components/toolbar/CustomToggleButtonGroup.tsx new file mode 100644 index 0000000000..3ee4460524 --- /dev/null +++ b/app/src/components/toolbar/CustomToggleButtonGroup.tsx @@ -0,0 +1,108 @@ +import Icon from '@mdi/react'; +import Button from '@mui/material/Button'; +import ToggleButton from '@mui/material/ToggleButton'; +import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + +export interface ToggleButtonView { + /** + * The value of the toggle button, which will be passed to the `onViewChange` callback. + * + * @type {ViewValueType} + * @memberof ToggleButtonView + */ + value: ViewValueType; + /** + * The label to display for the toggle button. + * + * @type {string} + * @memberof ToggleButtonView + */ + label: string; + /** + * An optional start icon. + * + * @type {string} + * @memberof ToggleButtonView + */ + icon?: string; +} + +interface CustomToggleButtonGroupProps { + /** + * An array of views to display in the toggle button group. + * + * @type {ToggleButtonView[]} + * @memberof CustomToggleButtonGroupProps + */ + views: ToggleButtonView[]; + /** + * The currently active view. + * + * @type {ViewValueType} + * @memberof CustomToggleButtonGroupProps + */ + activeView: ViewValueType; + /** + * Callback fired when a toggle button is clicked. + * + * @memberof CustomToggleButtonGroupProps + */ + onViewChange: (view: ViewValueType) => void; + /** + * The orientation of the toggle button group. + * + * @type {('horizontal' | 'vertical')} + * @memberof CustomToggleButtonGroupProps + */ + orientation: 'horizontal' | 'vertical'; +} + +/** + * A custom toggle button group that allows users to select from multiple views. + * + * @template ViewValueType + * @param {CustomToggleButtonGroupProps} props + * @return {*} + */ +const CustomToggleButtonGroup = (props: CustomToggleButtonGroupProps) => { + const { views, activeView, onViewChange, orientation } = props; + + return ( + { + if (view) { + onViewChange(view); + } + }} + exclusive + sx={{ + display: 'flex', + flex: '1 1 auto', + gap: 0.5, + '& Button': { + py: 1, + px: 2, + border: 'none', + borderRadius: '4px !important', + fontSize: '0.875rem', + fontWeight: 700, + letterSpacing: '0.02rem', + justifyContent: 'flex-start' + } + }}> + {views.map((view) => { + const startIcon = (view.icon && ) || undefined; + + return ( + + {view.label} + + ); + })} + + ); +}; + +export default CustomToggleButtonGroup; diff --git a/app/src/constants/i18n.ts b/app/src/constants/i18n.ts index 068bd3f0fb..6ee3b34128 100644 --- a/app/src/constants/i18n.ts +++ b/app/src/constants/i18n.ts @@ -498,3 +498,27 @@ export const SurveyExportI18N = { exportErrorText: 'An error has occurred while attempting to export survey data. Please try again. If the error persists, please contact your system administrator.' }; + +export const AlertI18N = { + cancelTitle: 'Discard changes and exit?', + cancelText: 'Any changes you have made will not be saved. Do you want to proceed?', + + createAlertDialogTitle: 'Create Alert', + createAlertDialogText: + 'Enter a name, message, and type for the alert. The name and message will be displayed on the alert banner.', + createErrorTitle: 'Error Creating Alert', + createErrorText: + 'An error has occurred while attempting to create your alert, please try again. If the error persists, please contact your system administrator.', + + updateAlertDialogTitle: 'Edit Alert Details', + updateAlertDialogText: 'Edit the name, description and effective dates for this alert.', + updateErrorTitle: 'Error Updating Alert', + updateErrorText: + 'An error has occurred while attempting to update your Alert, please try again. If the error persists, please contact your system administrator.', + + deleteAlertErrorTitle: 'Error Deleting a Alert', + deleteAlertErrorText: + 'An error has occurred while attempting to delete the Alerts, please try again. If the error persists, please contact your system administrator.', + deleteAlertDialogTitle: 'Delete Alert?', + deleteAlertDialogText: 'Are you sure you want to permanently delete this alert? This action cannot be undone.' +}; diff --git a/app/src/constants/roles.ts b/app/src/constants/roles.ts index 6f3dd340c4..fecb343fae 100644 --- a/app/src/constants/roles.ts +++ b/app/src/constants/roles.ts @@ -1,4 +1,4 @@ -import { mdiAccountEdit, mdiStar } from '@mdi/js'; +import { mdiAccountEdit, mdiEye, mdiStar } from '@mdi/js'; /** * System level roles. @@ -41,8 +41,8 @@ export enum PROJECT_PERMISSION { * * @export */ -export const PROJECT_ROLE_ICONS: Record = { +export const PROJECT_ROLE_ICONS: Record = { Coordinator: mdiStar, Collaborator: mdiAccountEdit, - Observer: undefined + Observer: mdiEye }; diff --git a/app/src/contexts/dialogContext.tsx b/app/src/contexts/dialogContext.tsx index 714117fcc7..4055c4c623 100644 --- a/app/src/contexts/dialogContext.tsx +++ b/app/src/contexts/dialogContext.tsx @@ -2,6 +2,7 @@ import CloseIcon from '@mui/icons-material/Close'; import IconButton from '@mui/material/IconButton'; import Snackbar from '@mui/material/Snackbar'; import { ErrorDialog, IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import ScoreDialog, { IScoreDialogProps } from 'components/dialog/ScoreDialog'; import YesNoDialog, { IYesNoDialogProps } from 'components/dialog/YesNoDialog'; import React, { createContext, ReactNode, useState } from 'react'; @@ -51,6 +52,21 @@ export interface IDialogContext { * @memberof IDialogContext */ snackbarProps: ISnackbarProps; + /** + * Set the score dialog props. + * + * Note: Any props that are not provided, will default to whatever value was previously set (or the default value) + * + * @memberof IDialogContext + */ + setScoreDialog: (props: Partial) => void; + /** + * The current score dialog props. + * + * @type {IScoreDialogProps} + * @memberof IDialogContext + */ + scoreDialogProps: IScoreDialogProps; } export interface ISnackbarProps { @@ -92,6 +108,22 @@ export const defaultSnackbarProps: ISnackbarProps = { open: false }; +export const defaultScoreDialogProps: IScoreDialogProps = { + dialogTitle: '', + dialogText: '', + dialogContent: <>, + open: false, + onClose: () => { + // default do nothing + }, + onOk: () => { + // default do nothing + }, + onSubmit: () => { + // default do nothing + } +}; + export const DialogContext = createContext({ setYesNoDialog: () => { // default do nothing @@ -104,7 +136,11 @@ export const DialogContext = createContext({ setSnackbar: () => { // default do nothing }, - snackbarProps: defaultSnackbarProps + snackbarProps: defaultSnackbarProps, + setScoreDialog: () => { + // default do nothing + }, + scoreDialogProps: defaultScoreDialogProps }); /** @@ -118,6 +154,8 @@ export const DialogContextProvider: React.FC = (props) const [errorDialogProps, setErrorDialogProps] = useState(defaultErrorDialogProps); + const [scoreDialogProps, setScoreDialogProps] = useState(defaultScoreDialogProps); + const [snackbarProps, setSnackbarProps] = useState(defaultSnackbarProps); const setYesNoDialog = function (partialProps: Partial) { @@ -132,6 +170,10 @@ export const DialogContextProvider: React.FC = (props) setErrorDialogProps({ ...errorDialogProps, ...partialProps }); }; + const setScoreDialog = function (partialProps: Partial) { + setScoreDialogProps((prev) => ({ ...prev, ...partialProps })); + }; + return ( = (props) setErrorDialog, errorDialogProps, setSnackbar, - snackbarProps + snackbarProps, + setScoreDialog, + scoreDialogProps }}> {props.children} + >; + /** + * The row Id of the observation being commented on + */ + commentDialogParams: GridRenderEditCellParams | null; + /** + * Sets the row Id of the observation being commented on + */ + setCommentDialogParams: React.Dispatch>; }; export type IObservationsTableContextProviderProps = PropsWithChildren; @@ -304,6 +313,9 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Internal disabled state for the observations table, should not be used outside of this context const [_isDisabled, setIsDisabled] = useState(false); + // Stores the id of an observation row being commented on. When not null, the comment dialog is open. + const [commentDialogParams, setCommentDialogParams] = useState(null); + // Global disabled state for the observations table const isDisabled = useMemo(() => { return _isDisabled || observationsPageContext.isDisabled; @@ -326,7 +338,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Pagination model const [paginationModel, setPaginationModel] = useState({ page: 0, - pageSize: 50 + pageSize: 25 }); // Sort model @@ -492,6 +504,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex row.itis_tsn && getTsnMeasurementTypeDefinitionMap(row.itis_tsn); } + // TODO: Either latitude/longitude OR sampling period is required, and either observation date OR sampling period is required const requiredStandardColumns: (keyof IObservationTableRow)[] = [ 'observation_subcount_sign_id', 'count', @@ -913,7 +926,8 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex latitude: null as unknown as number, longitude: null as unknown as number, itis_tsn: null as unknown as number, - itis_scientific_name: '' + itis_scientific_name: '', + comment: '' }; // Append new record to start of staged rows @@ -1156,6 +1170,7 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex // Why?: Currently there is no UI support for setting a subcount value. // See https://apps.nrs.gov.bc.ca/int/jira/browse/SIMSBIOHUB-534 subcount: row.count, + comment: row.comment, observation_subcount_sign_id: row.observation_subcount_sign_id, qualitative_measurements: measurementsToSave.qualitative, quantitative_measurements: measurementsToSave.quantitative, @@ -1243,6 +1258,8 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex observation_subcount_id: subcountRow.observation_subcount_id, // Add the subcount sign data into the row observation_subcount_sign_id: subcountRow.observation_subcount_sign_id, + // // Add the subcount comment into the row + comment: subcountRow.comment, // Reduce the array of qualitative measurements into an object and spread into the row ...subcountRow.qualitative_measurements.reduce((acc, cur) => { @@ -1522,7 +1539,9 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex environmentColumns, setEnvironmentColumns, isDisabled, - setIsDisabled + setIsDisabled, + commentDialogParams, + setCommentDialogParams }), [ _muiDataGridApiRef, @@ -1552,7 +1571,8 @@ export const ObservationsTableContextProvider = (props: IObservationsTableContex sortModel, measurementColumns, environmentColumns, - isDisabled + isDisabled, + commentDialogParams ] ); diff --git a/app/src/contexts/surveyContext.tsx b/app/src/contexts/surveyContext.tsx index 2443b62611..9dcfef9477 100644 --- a/app/src/contexts/surveyContext.tsx +++ b/app/src/contexts/surveyContext.tsx @@ -1,9 +1,7 @@ import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; -import { IGetSampleSiteResponse } from 'interfaces/useSamplingSiteApi.interface'; import { IGetSurveyAttachmentsResponse, IGetSurveyForViewResponse } from 'interfaces/useSurveyApi.interface'; -import { IGetTechniquesResponse } from 'interfaces/useTechniqueApi.interface'; import { createContext, PropsWithChildren, useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; @@ -30,22 +28,6 @@ export interface ISurveyContext { */ artifactDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>; - /** - * The Data Loader used to load survey sample site data - * - * @type {DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>} - * @memberof ISurveyContext - */ - sampleSiteDataLoader: DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>; - - /** - * The Data Loader used to load survey techniques - * - * @type {DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>} - * @memberof ISurveyContext - */ - techniqueDataLoader: DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>; - /** * The Data Loader used to load critters for a given survey * @@ -74,8 +56,6 @@ export interface ISurveyContext { export const SurveyContext = createContext({ surveyDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSurveyForViewResponse, unknown>, artifactDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSurveyAttachmentsResponse, unknown>, - sampleSiteDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetSampleSiteResponse, unknown>, - techniqueDataLoader: {} as DataLoader<[project_id: number, survey_id: number], IGetTechniquesResponse, unknown>, critterDataLoader: {} as DataLoader<[project_id: number, survey_id: number], ICritterSimpleResponse[], unknown>, projectId: -1, surveyId: -1 @@ -85,9 +65,7 @@ export const SurveyContextProvider = (props: PropsWithChildren = useParams(); @@ -108,7 +86,6 @@ export const SurveyContextProvider = (props: PropsWithChildren{props.children}; }; diff --git a/app/src/features/admin/AdminManagePage.tsx b/app/src/features/admin/AdminManagePage.tsx new file mode 100644 index 0000000000..07a7db5cbb --- /dev/null +++ b/app/src/features/admin/AdminManagePage.tsx @@ -0,0 +1,67 @@ +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; +import PageHeader from 'components/layout/PageHeader'; +import { AdministrativeActivityStatusType, AdministrativeActivityType } from 'constants/misc'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; +import AlertContainer from './alert/AlertContainer'; +import AccessRequestContainer from './users/access-requests/AccessRequestContainer'; +import ActiveUsersList from './users/active/ActiveUsersList'; + +/** + * Page to display admin functionality for managing users, alerts, etc. + * + * @return {*} + */ +const AdminManagePage = () => { + const biohubApi = useBiohubApi(); + + // ACCESS REQUESTS + const accessRequestsDataLoader = useDataLoader(() => + biohubApi.admin.getAdministrativeActivities( + [AdministrativeActivityType.SYSTEM_ACCESS], + [ + AdministrativeActivityStatusType.PENDING, + AdministrativeActivityStatusType.REJECTED, + AdministrativeActivityStatusType.ACTIONED + ] + ) + ); + + useEffect(() => { + accessRequestsDataLoader.load(); + }, [accessRequestsDataLoader]); + + // ACTIVE USERS + const activeUsersDataLoader = useDataLoader(() => biohubApi.user.getUsersList()); + useEffect(() => { + activeUsersDataLoader.load(); + }, [activeUsersDataLoader]); + + const refreshAccessRequests = () => { + accessRequestsDataLoader.refresh(); + activeUsersDataLoader.refresh(); + }; + + const refreshActiveUsers = () => { + activeUsersDataLoader.refresh(); + }; + + return ( + <> + + + + + + + + + + + + ); +}; + +export default AdminManagePage; diff --git a/app/src/features/admin/AdminUsersRouter.tsx b/app/src/features/admin/AdminRouter.tsx similarity index 58% rename from app/src/features/admin/AdminUsersRouter.tsx rename to app/src/features/admin/AdminRouter.tsx index 29daa60b0d..d1330e87c1 100644 --- a/app/src/features/admin/AdminUsersRouter.tsx +++ b/app/src/features/admin/AdminRouter.tsx @@ -2,31 +2,31 @@ import React from 'react'; import { Redirect, Route, Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; -import ManageUsersPage from './users/ManageUsersPage'; +import AdminManagePage from './AdminManagePage'; import UsersDetailPage from './users/projects/UsersDetailPage'; /** - * Router for all `/admin/users/*` pages. + * Router for all `/admin/manage/*` pages. * * @return {*} */ -const AdminUsersRouter: React.FC = () => { +const AdminRouter: React.FC = () => { return ( - - + + - + {/* Catch any unknown routes, and re-direct to the not found page */} - + ); }; -export default AdminUsersRouter; +export default AdminRouter; diff --git a/app/src/features/admin/alert/AlertContainer.tsx b/app/src/features/admin/alert/AlertContainer.tsx new file mode 100644 index 0000000000..0da1f0bbe5 --- /dev/null +++ b/app/src/features/admin/alert/AlertContainer.tsx @@ -0,0 +1,111 @@ +import { mdiCheck, mdiExclamationThick, mdiPlus } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlertFilterParams } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import CreateAlert from './create/CreateAlert'; +import DeleteAlert from './delete/DeleteAlert'; +import EditAlert from './edit/EditAlert'; +import AlertTable from './table/AlertTable'; + +enum AlertViewEnum { + ACTIVE = 'ACTIVE', + EXPIRED = 'EXPIRED' +} + +/** + * Container for displaying a list of alerts created by system administrators + */ +const AlertListContainer = () => { + const biohubApi = useBiohubApi(); + const [activeView, setActiveView] = useState(AlertViewEnum.ACTIVE); + const [modalState, setModalState] = useState({ + create: false, + edit: false, + delete: false + }); + const [alertId, setAlertId] = useState(null); + + const filters: IAlertFilterParams = + activeView === AlertViewEnum.ACTIVE ? { expiresAfter: dayjs().format() } : { expiresBefore: dayjs().format() }; + + // Load alerts based on filters + const alertDataLoader = useDataLoader((filters: IAlertFilterParams) => biohubApi.alert.getAlerts(filters)); + + // Define views + const views = [ + { value: AlertViewEnum.ACTIVE, label: 'Active', icon: mdiExclamationThick }, + { value: AlertViewEnum.EXPIRED, label: 'Expired', icon: mdiCheck } + ]; + + const closeModal = () => { + alertDataLoader.refresh(filters); + setModalState({ create: false, edit: false, delete: false }); + setAlertId(null); + }; + + useEffect(() => { + alertDataLoader.refresh(filters); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); + + return ( + + + + Alerts  + + + + + + setActiveView(view)} + orientation="horizontal" + /> + + + + {/* Modals */} + + {alertId && modalState.edit && } + {alertId && modalState.delete && ( + + )} + + { + setAlertId(id); + setModalState((prev) => ({ ...prev, edit: true })); + }} + onDelete={(id) => { + setAlertId(id); + setModalState((prev) => ({ ...prev, delete: true })); + }} + /> + + + ); +}; + +export default AlertListContainer; diff --git a/app/src/features/admin/alert/create/CreateAlert.tsx b/app/src/features/admin/alert/create/CreateAlert.tsx new file mode 100644 index 0000000000..58253fa666 --- /dev/null +++ b/app/src/features/admin/alert/create/CreateAlert.tsx @@ -0,0 +1,118 @@ +import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { AlertI18N } from 'constants/i18n'; +import { ISnackbarProps } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import { AlertSeverity, IAlertCreateObject } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import yup from 'utils/YupSchema'; +import AlertForm from '../form/AlertForm'; + +const AlertYupSchema = yup.object().shape({ + name: yup.string().trim().max(50, 'Name cannot exceed 50 characters').required('Name is required'), + message: yup.string().max(250, 'Message cannot exceed 250 characters').required('Message is required'), + alert_type_id: yup.number().integer().required('Page is required'), + severity: yup.string().required('Style is required'), + record_end_date: yup.string().isValidDateString().nullable() +}); + +interface ICreateAlertProps { + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog containing the alert form for creating a new system alert + * + * @param {ICreateAlertProps} props + */ +const CreateAlert = (props: ICreateAlertProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const dialogContext = useDialogContext(); + const codesContext = useCodesContext(); + + const biohubApi = useBiohubApi(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext]); + + const alertTypeOptions = + codesContext.codesDataLoader.data?.alert_types.map((type) => ({ + value: type.id, + label: type.name + })) ?? []; + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: AlertI18N.createErrorTitle, + dialogText: AlertI18N.createErrorText, + onClose: () => dialogContext.setErrorDialog({ open: false }), + onOk: () => dialogContext.setErrorDialog({ open: false }), + ...textDialogProps, + open: true + }); + }; + + const handleSubmitAlert = async (values: IAlertCreateObject) => { + try { + setIsSubmitting(true); + + await biohubApi.alert.createAlert(values); + + // creation was a success, tell parent to refresh + props.onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert '{values.name}' created + + ), + open: true + }); + } catch (error: any) { + showCreateErrorDialog({ + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError).errors + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + , + initialValues: { + name: '', + message: '', + alert_type_id: '' as unknown as number, + severity: 'info' as AlertSeverity, + data: null, + record_end_date: null + }, + validationSchema: AlertYupSchema + }} + dialogSaveButtonLabel="Create" + onCancel={() => props.onClose()} + onSave={(formValues) => { + handleSubmitAlert(formValues); + }} + /> + ); +}; + +export default CreateAlert; diff --git a/app/src/features/admin/alert/delete/DeleteAlert.tsx b/app/src/features/admin/alert/delete/DeleteAlert.tsx new file mode 100644 index 0000000000..c836e1c484 --- /dev/null +++ b/app/src/features/admin/alert/delete/DeleteAlert.tsx @@ -0,0 +1,91 @@ +import Typography from '@mui/material/Typography'; +import YesNoDialog from 'components/dialog/YesNoDialog'; +import { AlertI18N } from 'constants/i18n'; +import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { useContext, useEffect } from 'react'; + +interface IDeleteAlertProps { + alertId: number; + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog for deleting an alert + * + * @param {IDeleteAlertProps} props + * @returns + */ +const DeleteAlert = (props: IDeleteAlertProps) => { + const { alertId, open, onClose } = props; + const dialogContext = useContext(DialogContext); + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => biohubApi.alert.getAlertById(alertId)); + + useEffect(() => { + alertDataLoader.load(); + }, [alertDataLoader]); + + // API Error dialog + const showDeleteErrorDialog = () => { + dialogContext.setYesNoDialog({ + dialogTitle: AlertI18N.deleteAlertErrorTitle, + dialogText: AlertI18N.deleteAlertErrorText, + open: true, + onYes: async () => dialogContext.setYesNoDialog({ open: false }), + onClose: () => dialogContext.setYesNoDialog({ open: false }) + }); + }; + + // Success snack bar + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + + const deleteAlert = async () => { + try { + await biohubApi.alert.deleteAlert(alertId); + // delete was a success, tell parent to refresh + onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert deleted + + ), + open: true + }); + } catch (error) { + // error deleting, show dialog that says you need to remove references + onClose(false); + showDeleteErrorDialog(); + } + }; + + if (!alertDataLoader.isReady || !alertDataLoader.data) { + return <>; + } + + return ( + { + deleteAlert(); + }} + onClose={() => {}} + onNo={() => onClose()} + /> + ); +}; + +export default DeleteAlert; diff --git a/app/src/features/admin/alert/edit/EditAlert.tsx b/app/src/features/admin/alert/edit/EditAlert.tsx new file mode 100644 index 0000000000..0a7cc993d2 --- /dev/null +++ b/app/src/features/admin/alert/edit/EditAlert.tsx @@ -0,0 +1,130 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import EditDialog from 'components/dialog/EditDialog'; +import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; +import { AlertI18N } from 'constants/i18n'; +import { ISnackbarProps } from 'contexts/dialogContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext, useDialogContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlertUpdateObject } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; +import yup from 'utils/YupSchema'; +import AlertForm from '../form/AlertForm'; + +interface IEditAlertProps { + alertId: number; + open: boolean; + onClose: (refresh?: boolean) => void; +} + +/** + * Dialog containing the alert form for editing an existing system alert + * + * @param {IEditAlertProps} props + * + */ +const EditAlert = (props: IEditAlertProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + const dialogContext = useDialogContext(); + const codesContext = useCodesContext(); + + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => biohubApi.alert.getAlertById(props.alertId)); + + useEffect(() => { + alertDataLoader.load(); + codesContext.codesDataLoader.load(); + }, [alertDataLoader, codesContext]); + + const alertTypeOptions = + codesContext.codesDataLoader.data?.alert_types.map((type) => ({ + value: type.id, + label: type.name + })) ?? []; + + // This is placed inside the `EditAlert` component to make use of an API call to check for used names + // The API call would violate the rules of react hooks if placed in an object outside of the component + // Reference: https://react.dev/warnings/invalid-hook-call-warning + const AlertYupSchema = yup.object().shape({ + name: yup.string().trim().max(50, 'Name cannot exceed 50 characters').required('Name is required'), + message: yup.string().max(250, 'Message cannot exceed 250 characters').required('Message is required'), + record_end_date: yup.string().isValidDateString().nullable() + }); + + const showSnackBar = (textDialogProps?: Partial) => { + dialogContext.setSnackbar({ ...textDialogProps, open: true }); + }; + const showCreateErrorDialog = (textDialogProps?: Partial) => { + dialogContext.setErrorDialog({ + dialogTitle: AlertI18N.updateErrorTitle, + dialogText: AlertI18N.updateErrorText, + onClose: () => dialogContext.setErrorDialog({ open: false }), + onOk: () => dialogContext.setErrorDialog({ open: false }), + ...textDialogProps, + open: true + }); + }; + + const handleSubmit = async (values: IAlertUpdateObject) => { + try { + setIsSubmitting(true); + + await biohubApi.alert.updateAlert(values); + + // creation was a success, tell parent to refresh + props.onClose(true); + + showSnackBar({ + snackbarMessage: ( + + Alert '{values.name}' saved + + ), + open: true + }); + } catch (error: any) { + showCreateErrorDialog({ + dialogError: (error as APIError).message, + dialogErrorDetails: (error as APIError).errors + }); + } finally { + setIsSubmitting(false); + } + }; + + if (!alertDataLoader.isReady || !alertDataLoader.data) { + return ; + } + + return ( + , + initialValues: { + alert_id: alertDataLoader.data.alert_id, + name: alertDataLoader.data.name, + message: alertDataLoader.data.message, + alert_type_id: alertDataLoader.data.alert_type_id, + severity: alertDataLoader.data.severity, + data: alertDataLoader.data.data, + record_end_date: alertDataLoader.data.record_end_date + }, + validationSchema: AlertYupSchema + }} + dialogSaveButtonLabel="Save" + onCancel={() => props.onClose()} + onSave={(formValues) => { + handleSubmit(formValues); + }} + /> + ); +}; + +export default EditAlert; diff --git a/app/src/features/admin/alert/form/AlertForm.tsx b/app/src/features/admin/alert/form/AlertForm.tsx new file mode 100644 index 0000000000..3190abf843 --- /dev/null +++ b/app/src/features/admin/alert/form/AlertForm.tsx @@ -0,0 +1,81 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; +import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/AutocompleteField'; +import CustomTextField from 'components/fields/CustomTextField'; +import { DateField } from 'components/fields/DateField'; +import { useFormikContext } from 'formik'; +import { IAlertCreateObject } from 'interfaces/useAlertApi.interface'; + +interface IAlertFormProps { + alertTypeOptions: IAutocompleteFieldOption[]; +} + +/** + * Form used to create and update system alerts, used by system administrators + * + */ +const AlertForm = (props: IAlertFormProps) => { + const { alertTypeOptions } = props; + + const { values } = useFormikContext(); + + return ( + <> +
+ + Display information + + + + + + + + + + + Expiry date (optional) + + + + +
+ + Preview + + + + ); +}; + +export default AlertForm; diff --git a/app/src/features/admin/alert/table/AlertTable.tsx b/app/src/features/admin/alert/table/AlertTable.tsx new file mode 100644 index 0000000000..47793fdb16 --- /dev/null +++ b/app/src/features/admin/alert/table/AlertTable.tsx @@ -0,0 +1,121 @@ +import Box from '@mui/material/Box'; +import { green, red } from '@mui/material/colors'; +import { GridColDef } from '@mui/x-data-grid'; +import AlertBar from 'components/alert/AlertBar'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useCodesContext } from 'hooks/useContext'; +import { AlertSeverity, IAlert } from 'interfaces/useAlertApi.interface'; +import AlertTableActionsMenu from './components/AlertTableActionsMenu'; + +export interface IAlertTableTableProps { + alerts: IAlert[]; + onEdit: (alertId: number) => void; + onDelete: (alertId: number) => void; +} + +export interface IAlertTableRow { + id: number; + alert_type_id: number; + severity: AlertSeverity; + name: string; + message: string; + data: object | null; + record_end_date: string | null; + status: 'expired' | 'active'; +} + +/** + * Data grid table displaying alerts created by system administrators + * + * @param {IAlertTableTableProps} props + */ +const AlertTable = (props: IAlertTableTableProps) => { + const codesContext = useCodesContext(); + + const rows: IAlertTableRow[] = props.alerts.map((alert) => ({ ...alert, id: alert.alert_id })); + + const columns: GridColDef[] = [ + { + field: 'preview', + headerName: 'Alert', + flex: 1, + renderCell: (params) => ( + + + + ) + }, + { + field: 'alert_type_id', + headerName: 'Page', + description: 'Page that the alert displays on.', + headerAlign: 'left', + align: 'left', + width: 150, + renderCell: (params) => + codesContext.codesDataLoader.data?.alert_types.find((code) => code.id === params.row.alert_type_id)?.name + }, + { + field: 'record_end_date', + headerName: 'Expiry date', + description: 'Status of the alert.', + headerAlign: 'left', + align: 'left', + width: 150, + renderCell: (params) => + params.row.record_end_date ? dayjs(params.row.record_end_date).format(DATE_FORMAT.MediumDateFormat) : null + }, + { + field: 'status', + headerName: 'Status', + description: 'Status of the alert.', + headerAlign: 'center', + align: 'center', + width: 150, + renderCell: (params) => ( + + ) + }, + { + field: 'actions', + type: 'actions', + sortable: false, + align: 'right', + flex: 0, + renderCell: (params) => ( + + ) + } + ]; + + return ( + 'auto'} + rows={rows} + getRowId={(row) => `alert-${row.alert_id}`} + columns={columns} + pageSizeOptions={[5]} + rowSelection={false} + checkboxSelection={false} + hideFooter + disableRowSelectionOnClick + disableColumnSelector + disableColumnFilter + disableColumnMenu + sortingOrder={['asc', 'desc']} + data-testid="alert-table" + /> + ); +}; + +export default AlertTable; diff --git a/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx b/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx new file mode 100644 index 0000000000..17a7743fe7 --- /dev/null +++ b/app/src/features/admin/alert/table/components/AlertTableActionsMenu.tsx @@ -0,0 +1,81 @@ +import { mdiDotsVertical, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import { useState } from 'react'; + +export interface IAlertTableActionsMenuProps { + alertId: number; + onEdit: (alertId: number) => void; + onDelete: (alertId: number) => void; +} + +/** + * Actions displayed in the context menu of an alert row in the alert table data grid + * + * @param {IAlertTableActionsMenuProps} props + */ +const AlertTableActionsMenu = (props: IAlertTableActionsMenuProps) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (event: any) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + + + { + handleClose(); + props.onEdit(props.alertId); + }} + data-testid="alert-table-row-edit"> + + + + Edit + + { + handleClose(); + props.onDelete(props.alertId); + }} + data-testid="alert-table-row-delete"> + + + + Delete + + + + ); +}; + +export default AlertTableActionsMenu; diff --git a/app/src/features/admin/users/ManageUsersPage.test.tsx b/app/src/features/admin/users/ManageUsersPage.test.tsx deleted file mode 100644 index ea17d4e119..0000000000 --- a/app/src/features/admin/users/ManageUsersPage.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { AuthStateContext } from 'contexts/authStateContext'; -import { CodesContext, ICodesContext } from 'contexts/codesContext'; -import { createMemoryHistory } from 'history'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { DataLoader } from 'hooks/useDataLoader'; -import { Router } from 'react-router'; -import { getMockAuthState, SystemAdminAuthState } from 'test-helpers/auth-helpers'; -import { codes } from 'test-helpers/code-helpers'; -import { cleanup, render, waitFor } from 'test-helpers/test-utils'; -import ManageUsersPage from './ManageUsersPage'; - -const history = createMemoryHistory(); - -const renderContainer = () => { - const authState = getMockAuthState({ base: SystemAdminAuthState }); - - const mockCodesContext: ICodesContext = { - codesDataLoader: { - data: codes, - load: () => {} - } as DataLoader - }; - - return render( - - - - - - - - ); -}; - -jest.mock('../../../hooks/useBioHubApi'); -const mockBiohubApi = useBiohubApi as jest.Mock; - -const mockUseApi = { - admin: { - getAdministrativeActivities: jest.fn() - }, - user: { - getUsersList: jest.fn() - }, - codes: { - getAllCodeSets: jest.fn() - } -}; - -describe('ManageUsersPage', () => { - beforeEach(() => { - mockBiohubApi.mockImplementation(() => mockUseApi); - mockUseApi.admin.getAdministrativeActivities.mockClear(); - mockUseApi.user.getUsersList.mockClear(); - mockUseApi.codes.getAllCodeSets.mockClear(); - - // mock code set response - mockUseApi.codes.getAllCodeSets.mockReturnValue({ - system_roles: [{ id: 1, name: 'Role 1' }], - administrative_activity_status_type: [ - { id: 1, name: 'Actioned' }, - { id: 1, name: 'Rejected' } - ] - }); - }); - - afterEach(() => { - cleanup(); - }); - - it('renders the main page content correctly', async () => { - mockUseApi.admin.getAdministrativeActivities.mockReturnValue([]); - mockUseApi.user.getUsersList.mockReturnValue([]); - - const { getByText } = renderContainer(); - - await waitFor(() => { - expect(getByText('Manage Users')).toBeVisible(); - }); - }); - - it('renders the access requests and active users component', async () => { - mockUseApi.admin.getAdministrativeActivities.mockReturnValue([]); - mockUseApi.user.getUsersList.mockReturnValue([]); - - const { getByText } = renderContainer(); - - await waitFor(() => { - expect(getByText('No Pending Access Requests')).toBeVisible(); - expect(getByText('No Active Users')).toBeVisible(); - }); - }); -}); diff --git a/app/src/features/admin/users/ManageUsersPage.tsx b/app/src/features/admin/users/ManageUsersPage.tsx deleted file mode 100644 index 9c11f7e044..0000000000 --- a/app/src/features/admin/users/ManageUsersPage.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import Container from '@mui/material/Container'; -import PageHeader from 'components/layout/PageHeader'; -import { AdministrativeActivityStatusType, AdministrativeActivityType } from 'constants/misc'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; -import { ISystemUser } from 'interfaces/useUserApi.interface'; -import React, { useEffect, useState } from 'react'; -import AccessRequestContainer from './access-requests/AccessRequestContainer'; -import ActiveUsersList from './active/ActiveUsersList'; - -/** - * Page to display user management data/functionality. - * - * @return {*} - */ -const ManageUsersPage: React.FC = () => { - const biohubApi = useBiohubApi(); - - const [accessRequests, setAccessRequests] = useState([]); - const [isLoadingAccessRequests, setIsLoadingAccessRequests] = useState(false); - const [hasLoadedAccessRequests, setHasLoadedAccessRequests] = useState(false); - - const [activeUsers, setActiveUsers] = useState([]); - const [isLoadingActiveUsers, setIsLoadingActiveUsers] = useState(false); - const [hasLoadedActiveUsers, setHasLoadedActiveUsers] = useState(false); - - const [codes, setCodes] = useState(); - const [isLoadingCodes, setIsLoadingCodes] = useState(false); - - const refreshAccessRequests = async () => { - const accessResponse = await biohubApi.admin.getAdministrativeActivities( - [AdministrativeActivityType.SYSTEM_ACCESS], - [ - AdministrativeActivityStatusType.PENDING, - AdministrativeActivityStatusType.REJECTED, - AdministrativeActivityStatusType.ACTIONED - ] - ); - - setAccessRequests(accessResponse); - }; - - useEffect(() => { - const getAccessRequests = async () => { - const accessResponse = await biohubApi.admin.getAdministrativeActivities( - [AdministrativeActivityType.SYSTEM_ACCESS], - [ - AdministrativeActivityStatusType.PENDING, - AdministrativeActivityStatusType.REJECTED, - AdministrativeActivityStatusType.ACTIONED - ] - ); - - setAccessRequests(() => { - setHasLoadedAccessRequests(true); - setIsLoadingAccessRequests(false); - return accessResponse; - }); - }; - - if (isLoadingAccessRequests || hasLoadedAccessRequests) { - return; - } - - setIsLoadingAccessRequests(true); - - getAccessRequests(); - }, [biohubApi.admin, isLoadingAccessRequests, hasLoadedAccessRequests]); - - const refreshActiveUsers = async () => { - const activeUsersResponse = await biohubApi.user.getUsersList(); - - setActiveUsers(activeUsersResponse); - }; - - useEffect(() => { - const getActiveUsers = async () => { - const activeUsersResponse = await biohubApi.user.getUsersList(); - - setActiveUsers(() => { - setHasLoadedActiveUsers(true); - setIsLoadingActiveUsers(false); - return activeUsersResponse; - }); - }; - - if (hasLoadedActiveUsers || isLoadingActiveUsers) { - return; - } - - setIsLoadingActiveUsers(true); - - getActiveUsers(); - }, [biohubApi, isLoadingActiveUsers, hasLoadedActiveUsers]); - - useEffect(() => { - const getCodes = async () => { - const codesResponse = await biohubApi.codes.getAllCodeSets(); - - if (!codesResponse) { - // TODO error handling/messaging - return; - } - - setCodes(() => { - setIsLoadingCodes(false); - return codesResponse; - }); - }; - - if (isLoadingCodes || codes) { - return; - } - - setIsLoadingCodes(true); - - getCodes(); - }, [biohubApi.codes, isLoadingCodes, codes]); - - if (!hasLoadedAccessRequests || !hasLoadedActiveUsers || !codes) { - return ; - } - - return ( - <> - - - { - refreshAccessRequests(); - refreshActiveUsers(); - }} - /> - - - - - - ); -}; - -export default ManageUsersPage; diff --git a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx index 051a9616ce..336bd4982b 100644 --- a/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx +++ b/app/src/features/admin/users/access-requests/AccessRequestContainer.tsx @@ -1,15 +1,11 @@ import { mdiCancel, mdiCheck, mdiExclamationThick } from '@mdi/js'; -import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup/ToggleButtonGroup'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { useState } from 'react'; import AccessRequestActionedList from './list/actioned/AccessRequestActionedList'; import AccessRequestPendingList from './list/pending/AccessRequestPendingList'; @@ -17,7 +13,6 @@ import AccessRequestRejectedList from './list/rejected/AccessRequestRejectedList export interface IAccessRequestContainerProps { accessRequests: IGetAccessRequestsListResponse[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -32,19 +27,20 @@ enum AccessRequestViewEnum { * */ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { - const { accessRequests, codes, refresh } = props; - const [activeView, setActiveView] = useState(AccessRequestViewEnum.PENDING); + const { accessRequests, refresh } = props; - const views = [ - { value: AccessRequestViewEnum.PENDING, label: 'Pending', icon: mdiExclamationThick }, - { value: AccessRequestViewEnum.ACTIONED, label: 'Approved', icon: mdiCheck }, - { value: AccessRequestViewEnum.REJECTED, label: 'Rejected', icon: mdiCancel } - ]; + const [activeView, setActiveView] = useState(AccessRequestViewEnum.PENDING); const pendingRequests = accessRequests.filter((request) => request.status_name === 'Pending'); const actionedRequests = accessRequests.filter((request) => request.status_name === 'Actioned'); const rejectedRequests = accessRequests.filter((request) => request.status_name === 'Rejected'); + const views = [ + { value: AccessRequestViewEnum.PENDING, label: `Pending (${pendingRequests.length})`, icon: mdiExclamationThick }, + { value: AccessRequestViewEnum.ACTIONED, label: `Approved (${actionedRequests.length})`, icon: mdiCheck }, + { value: AccessRequestViewEnum.REJECTED, label: `Rejected (${rejectedRequests.length})`, icon: mdiCancel } + ]; + return ( @@ -54,60 +50,19 @@ const AccessRequestContainer = (props: IAccessRequestContainerProps) => { - { - if (!view) { - // An active view must be selected at all times - return; - } - + { setActiveView(view); }} - exclusive - sx={{ - width: '100%', - gap: 1, - '& Button': { - py: 0.5, - px: 1.5, - border: 'none !important', - fontWeight: 700, - borderRadius: '4px !important', - fontSize: '0.875rem', - letterSpacing: '0.02rem' - } - }}> - {views.map((view) => { - const getCount = () => { - switch (view.value) { - case AccessRequestViewEnum.PENDING: - return pendingRequests.length; - case AccessRequestViewEnum.ACTIONED: - return actionedRequests.length; - case AccessRequestViewEnum.REJECTED: - return rejectedRequests.length; - default: - return 0; - } - }; - return ( - }> - {view.label} ({getCount()}) - - ); - })} - + orientation="horizontal" + /> - + {activeView === AccessRequestViewEnum.PENDING && ( - + )} {activeView === AccessRequestViewEnum.ACTIONED && ( diff --git a/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx index 8144e3da8c..07a362026c 100644 --- a/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx +++ b/app/src/features/admin/users/access-requests/list/pending/AccessRequestPendingList.tsx @@ -11,9 +11,9 @@ import { DialogContext, ISnackbarProps } from 'contexts/dialogContext'; import dayjs from 'dayjs'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useCodesContext } from 'hooks/useContext'; import { IGetAccessRequestsListResponse } from 'interfaces/useAdminApi.interface'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; -import { useContext, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import ReviewAccessRequestForm, { IReviewAccessRequestForm, ReviewAccessRequestFormInitialValues, @@ -22,7 +22,6 @@ import ReviewAccessRequestForm, { interface IAccessRequestPendingListProps { accessRequests: IGetAccessRequestsListResponse[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -33,7 +32,10 @@ interface IAccessRequestPendingListProps { * @returns */ const AccessRequestPendingList = (props: IAccessRequestPendingListProps) => { - const { accessRequests, codes, refresh } = props; + const { accessRequests, refresh } = props; + + const codesContext = useCodesContext(); + const codes = codesContext.codesDataLoader?.data; const biohubApi = useBiohubApi(); const dialogContext = useContext(DialogContext); @@ -41,6 +43,10 @@ const AccessRequestPendingList = (props: IAccessRequestPendingListProps) => { const [showReviewDialog, setShowReviewDialog] = useState(false); const [activeReview, setActiveReview] = useState(null); + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + const showSnackBar = (textDialogProps?: Partial) => { dialogContext.setSnackbar({ ...textDialogProps, open: true }); }; diff --git a/app/src/features/admin/users/active/ActiveUsersList.test.tsx b/app/src/features/admin/users/active/ActiveUsersList.test.tsx index 272db2df25..755bd0f8e8 100644 --- a/app/src/features/admin/users/active/ActiveUsersList.test.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.test.tsx @@ -60,7 +60,6 @@ describe('ActiveUsersList', () => { it('shows `No Active Users` when there are no active users', async () => { const { getByText } = renderContainer({ activeUsers: [], - codes: codes, refresh: () => {} }); @@ -72,7 +71,6 @@ describe('ActiveUsersList', () => { it('renders the add new users button correctly', async () => { const { getByTestId } = renderContainer({ activeUsers: [], - codes: codes, refresh: () => {} }); diff --git a/app/src/features/admin/users/active/ActiveUsersList.tsx b/app/src/features/admin/users/active/ActiveUsersList.tsx index f6380948e2..d2d988f07e 100644 --- a/app/src/features/admin/users/active/ActiveUsersList.tsx +++ b/app/src/features/admin/users/active/ActiveUsersList.tsx @@ -18,7 +18,6 @@ import { APIError } from 'hooks/api/useAxios'; import { useAuthStateContext } from 'hooks/useAuthStateContext'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useCodesContext } from 'hooks/useContext'; -import { IGetAllCodeSetsResponse } from 'interfaces/useCodesApi.interface'; import { ISystemUser } from 'interfaces/useUserApi.interface'; import { useContext, useEffect, useState } from 'react'; import { useHistory } from 'react-router'; @@ -32,7 +31,6 @@ import AddSystemUsersForm, { export interface IActiveUsersListProps { activeUsers: ISystemUser[]; - codes: IGetAllCodeSetsResponse; refresh: () => void; } @@ -43,7 +41,7 @@ const pageSizeOptions = [10, 25, 50]; * */ const ActiveUsersList = (props: IActiveUsersListProps) => { - const { activeUsers, codes, refresh } = props; + const { activeUsers, refresh } = props; const authStateContext = useAuthStateContext(); const biohubApi = useBiohubApi(); @@ -59,12 +57,18 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); + const codes = codesContext.codesDataLoader.data; + + if (!codes) { + return <>; + } + const activeUsersColumnDefs: GridColDef[] = [ { field: 'system_user_id', headerName: 'ID', - width: 70, - minWidth: 70, + width: 85, + minWidth: 85, renderHeader: () => ( ID @@ -86,7 +90,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { {params.row.display_name || 'No identifier'} @@ -159,7 +163,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { menuLabel: 'View Users Details', menuOnClick: () => history.push({ - pathname: `/admin/users/${params.row.system_user_id}`, + pathname: `/admin/manage/users/${params.row.system_user_id}`, state: params.row }) }, @@ -408,7 +412,7 @@ const ActiveUsersList = (props: IActiveUsersListProps) => { - + noRowsMessage="No Active Users" columns={activeUsersColumnDefs} diff --git a/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx index cda6451ea1..ebf91a260c 100644 --- a/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.test.tsx @@ -41,7 +41,7 @@ describe('UsersDetailHeader', () => { }); it('renders correctly when selectedUser are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByTestId } = render( @@ -56,7 +56,7 @@ describe('UsersDetailHeader', () => { describe('Are you sure? Dialog', () => { it('Remove User button opens dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -74,7 +74,7 @@ describe('UsersDetailHeader', () => { }); it('does nothing if the user clicks `Cancel` or away from the dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -93,16 +93,16 @@ describe('UsersDetailHeader', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); - it('deletes the user and routes user back to Manage Users page', async () => { + it('deletes the user and routes user back to Admin page', async () => { mockUseApi.user.deleteSystemUser.mockResolvedValue({ response: 200 } as any); - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByText, getByText } = render( @@ -121,7 +121,7 @@ describe('UsersDetailHeader', () => { fireEvent.click(getByText('Remove')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users'); + expect(history.location.pathname).toEqual('/admin/manage/users'); }); }); }); diff --git a/app/src/features/admin/users/projects/UsersDetailHeader.tsx b/app/src/features/admin/users/projects/UsersDetailHeader.tsx index 48d73acebd..ac52ecd4f7 100644 --- a/app/src/features/admin/users/projects/UsersDetailHeader.tsx +++ b/app/src/features/admin/users/projects/UsersDetailHeader.tsx @@ -79,7 +79,7 @@ const UsersDetailHeader: React.FC = (props) => { open: true }); - history.push('/admin/users'); + history.push('/admin/manage/users'); } catch (error) { openErrorDialog({ dialogTitle: SystemUserI18N.removeUserErrorTitle, @@ -93,8 +93,8 @@ const UsersDetailHeader: React.FC = (props) => { '}> - - Manage Users + + Admin {userDetails.display_name} diff --git a/app/src/features/admin/users/projects/UsersDetailPage.test.tsx b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx index e12cf2326b..dcf73b1557 100644 --- a/app/src/features/admin/users/projects/UsersDetailPage.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailPage.test.tsx @@ -51,7 +51,7 @@ describe('UsersDetailPage', () => { }); it('renders correctly when selectedUser are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.user.getUserById.mockResolvedValue({ system_user_id: 1, diff --git a/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx index ebac9cf029..4c26dbc221 100644 --- a/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx +++ b/app/src/features/admin/users/projects/UsersDetailProjects.test.tsx @@ -52,7 +52,7 @@ describe('UsersDetailProjects', () => { }); it('shows circular spinner when assignedProjects not yet loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); const { getAllByTestId } = render( @@ -66,7 +66,7 @@ describe('UsersDetailProjects', () => { }); it('renders empty list correctly when assignedProjects empty and loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }] @@ -87,7 +87,7 @@ describe('UsersDetailProjects', () => { }); it('renders list of a single project correctly when assignedProjects are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -119,7 +119,7 @@ describe('UsersDetailProjects', () => { }); it('renders list of a multiple projects correctly when assignedProjects are loaded', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -161,7 +161,7 @@ describe('UsersDetailProjects', () => { }); it('routes to project id details on click', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -199,7 +199,7 @@ describe('UsersDetailProjects', () => { describe('Are you sure? Dialog', () => { it('does nothing if the user clicks `Cancel` or away from the dialog', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -239,12 +239,12 @@ describe('UsersDetailProjects', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); it('deletes User from project if the user clicks on `Remove` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -317,7 +317,7 @@ describe('UsersDetailProjects', () => { describe('Change users Project Role', () => { it('renders list of roles to change per project', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -361,7 +361,7 @@ describe('UsersDetailProjects', () => { }); it('renders dialog pop on role selection, does nothing if user clicks `Cancel` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], @@ -414,12 +414,12 @@ describe('UsersDetailProjects', () => { fireEvent.click(getByText('Cancel')); await waitFor(() => { - expect(history.location.pathname).toEqual('/admin/users/1'); + expect(history.location.pathname).toEqual('/admin/manage/users/1'); }); }); it('renders dialog pop on role selection, Changes role on click of `Change Role` ', async () => { - history.push('/admin/users/1'); + history.push('/admin/manage/users/1'); mockUseApi.codes.getAllCodeSets.mockResolvedValue({ coordinator_agency: [{ id: 1, name: 'agency 1' }], diff --git a/app/src/features/admin/users/projects/UsersDetailProjects.tsx b/app/src/features/admin/users/projects/UsersDetailProjects.tsx index 47e7827515..397a80155c 100644 --- a/app/src/features/admin/users/projects/UsersDetailProjects.tsx +++ b/app/src/features/admin/users/projects/UsersDetailProjects.tsx @@ -163,7 +163,7 @@ const UsersDetailProjects: React.FC = (props) => { - + diff --git a/app/src/features/alert/banner/SystemAlertBanner.tsx b/app/src/features/alert/banner/SystemAlertBanner.tsx new file mode 100644 index 0000000000..8e5863969f --- /dev/null +++ b/app/src/features/alert/banner/SystemAlertBanner.tsx @@ -0,0 +1,116 @@ +import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import grey from '@mui/material/colors/grey'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import AlertBar from 'components/alert/AlertBar'; +import dayjs from 'dayjs'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IAlert, SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; +import { useEffect, useState } from 'react'; + +interface ISystemAlertBannerProps { + alertTypes?: SystemAlertBannerEnum[]; +} + +// The number of alerts to show on initial page load +const NumberOfAlertsShownInitially = 2; + +/** + * Stack of system alerts created by system administrators + * + * @param {ISystemAlertBannerProps} props + * @returns + */ +export const SystemAlertBanner = (props: ISystemAlertBannerProps) => { + const { alertTypes } = props; + + const biohubApi = useBiohubApi(); + + const alertDataLoader = useDataLoader(() => + biohubApi.alert.getAlerts({ types: alertTypes, expiresAfter: dayjs().format() }) + ); + + useEffect(() => { + alertDataLoader.load(); + }, [alertDataLoader]); + + const [isExpanded, setIsExpanded] = useState(false); + + const alerts = alertDataLoader.data?.alerts ?? []; + + const numberOfAlerts = alerts.length; + + const renderAlerts = (alerts: IAlert[]) => { + const visibleAlerts = []; + const collapsedAlerts = []; + + for (let index = 0; index < numberOfAlerts; index++) { + const alert = alerts[index]; + + const alertComponent = ( + + ); + + if (index < NumberOfAlertsShownInitially) { + visibleAlerts.push(alertComponent); + } else { + collapsedAlerts.push(alertComponent); + } + } + + return ( + + {visibleAlerts} + {collapsedAlerts.length > 0 && {collapsedAlerts}} + + ); + }; + + if (!numberOfAlerts) { + return null; + } + + return ( + + {renderAlerts(alerts)} + {numberOfAlerts > NumberOfAlertsShownInitially && ( + + )} + + ); +}; diff --git a/app/src/features/funding-sources/list/FundingSourcesListPage.tsx b/app/src/features/funding-sources/list/FundingSourcesListPage.tsx index 7b2360a41a..e303eb8b3c 100644 --- a/app/src/features/funding-sources/list/FundingSourcesListPage.tsx +++ b/app/src/features/funding-sources/list/FundingSourcesListPage.tsx @@ -10,10 +10,12 @@ import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import PageHeader from 'components/layout/PageHeader'; import { FundingSourceI18N } from 'constants/i18n'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import useDataLoader from 'hooks/useDataLoader'; import useDataLoaderError from 'hooks/useDataLoaderError'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import React, { useState } from 'react'; import CreateFundingSource from '../components/CreateFundingSource'; import DeleteFundingSource from '../components/DeleteFundingSource'; @@ -111,6 +113,7 @@ const FundingSourcesListPage: React.FC = () => { /> + diff --git a/app/src/features/projects/view/ProjectAttachments.tsx b/app/src/features/projects/view/ProjectAttachments.tsx index 174e6924d3..480edf4579 100644 --- a/app/src/features/projects/view/ProjectAttachments.tsx +++ b/app/src/features/projects/view/ProjectAttachments.tsx @@ -1,6 +1,5 @@ import { mdiAttachment, mdiFilePdfBox, mdiTrayArrowUp } from '@mdi/js'; import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; @@ -107,10 +106,10 @@ const ProjectAttachments = () => { )} /> - - - - + + + + ); }; diff --git a/app/src/features/projects/view/ProjectDetails.tsx b/app/src/features/projects/view/ProjectDetails.tsx index 2a36e2b6c4..abb8cd94fb 100644 --- a/app/src/features/projects/view/ProjectDetails.tsx +++ b/app/src/features/projects/view/ProjectDetails.tsx @@ -4,7 +4,9 @@ import useTheme from '@mui/material/styles/useTheme'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import assert from 'assert'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; import { ProjectContext } from 'contexts/projectContext'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { useContext } from 'react'; import ProjectObjectives from './components/ProjectObjectives'; import TeamMembers from './components/TeamMember'; @@ -62,9 +64,10 @@ const ProjectDetails = () => { return ( - + Project Details + diff --git a/app/src/features/projects/view/ProjectPage.test.tsx b/app/src/features/projects/view/ProjectPage.test.tsx index 4dae8ecbb5..cf32b11fd5 100644 --- a/app/src/features/projects/view/ProjectPage.test.tsx +++ b/app/src/features/projects/view/ProjectPage.test.tsx @@ -16,6 +16,12 @@ const history = createMemoryHistory({ initialEntries: ['/admin/projects/1'] }); jest.mock('../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; +jest.mock('../../../components/markdown/CustomMarkdown', () => { + // Overriding this component because it is ESM only and Jest does not support ESM. + // See https://github.com/orgs/remarkjs/discussions/1247 for more information. + return {}; +}); + const mockUseApi = { project: { getProjectForView: jest.fn, [number]>(), diff --git a/app/src/features/projects/view/ProjectPage.tsx b/app/src/features/projects/view/ProjectPage.tsx index e26ec4fdf7..6dd72d2423 100644 --- a/app/src/features/projects/view/ProjectPage.tsx +++ b/app/src/features/projects/view/ProjectPage.tsx @@ -5,8 +5,10 @@ import Grid from '@mui/material/Grid'; import Paper from '@mui/material/Paper'; import { CodesContext } from 'contexts/codesContext'; import { ProjectContext } from 'contexts/projectContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import ProjectAttachments from 'features/projects/view/ProjectAttachments'; import SurveysListPage from 'features/surveys/list/SurveysListPage'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useContext, useEffect } from 'react'; import ProjectDetails from './ProjectDetails'; import ProjectHeader from './ProjectHeader'; @@ -36,6 +38,7 @@ const ProjectPage = () => { <> + diff --git a/app/src/features/standards/StandardsPage.tsx b/app/src/features/standards/StandardsPage.tsx index 7c963b5d93..1b43308f82 100644 --- a/app/src/features/standards/StandardsPage.tsx +++ b/app/src/features/standards/StandardsPage.tsx @@ -6,8 +6,10 @@ import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; import PageHeader from 'components/layout/PageHeader'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useState } from 'react'; -import { StandardsToolbar } from './components/StandardsToolbar'; import { EnvironmentStandards } from './view/environment/EnvironmentStandards'; import { MethodStandards } from './view/methods/MethodStandards'; import { SpeciesStandards } from './view/species/SpeciesStandards'; @@ -18,42 +20,42 @@ export enum StandardsPageView { ENVIRONMENT = 'ENVIRONMENT' } -export interface IStandardsPageView { - label: string; - value: StandardsPageView; - icon: string; -} - const StandardsPage = () => { - const [currentView, setCurrentView] = useState(StandardsPageView.SPECIES); + const [activeView, setActiveView] = useState(StandardsPageView.SPECIES); - const views: IStandardsPageView[] = [ - { label: 'Species', value: StandardsPageView.SPECIES, icon: mdiPaw }, - { label: 'Sampling Methods', value: StandardsPageView.METHODS, icon: mdiToolbox }, - { label: 'Environment variables', value: StandardsPageView.ENVIRONMENT, icon: mdiLeaf } + const views = [ + { value: StandardsPageView.SPECIES, label: 'Species', icon: mdiPaw }, + { value: StandardsPageView.METHODS, label: 'Sampling Methods', icon: mdiToolbox }, + { value: StandardsPageView.ENVIRONMENT, label: 'Environment variables', icon: mdiLeaf } ]; return ( <> + {/* TOOLBAR FOR SWITCHING VIEWS */} - + setActiveView(view)} + orientation="vertical" + /> {/* SPECIES STANDARDS */} - {currentView === StandardsPageView.SPECIES && } + {activeView === StandardsPageView.SPECIES && } {/* METHOD STANDARDS */} - {currentView === StandardsPageView.METHODS && } + {activeView === StandardsPageView.METHODS && } {/* ENVIRONMENT STANDARDS */} - {currentView === StandardsPageView.ENVIRONMENT && } + {activeView === StandardsPageView.ENVIRONMENT && } diff --git a/app/src/features/standards/components/StandardsToolbar.tsx b/app/src/features/standards/components/StandardsToolbar.tsx deleted file mode 100644 index 44ff76623c..0000000000 --- a/app/src/features/standards/components/StandardsToolbar.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import Icon from '@mdi/react'; -import Button from '@mui/material/Button'; -import ToggleButton from '@mui/material/ToggleButton/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Typography from '@mui/material/Typography'; -import React, { SetStateAction } from 'react'; -import { IStandardsPageView, StandardsPageView } from '../StandardsPage'; - -interface IStandardsToolbar { - views: IStandardsPageView[]; - currentView: StandardsPageView; - setCurrentView: React.Dispatch>; -} - -/** - * Toolbar for setting the standards page view - * - * @param props - * @returns - */ -export const StandardsToolbar = (props: IStandardsToolbar) => { - const { views, currentView, setCurrentView } = props; - - return ( - <> - Data types - , view: StandardsPageView | null) => { - if (view) { - setCurrentView(view); - } - }} - exclusive - sx={{ - display: 'flex', - gap: 1, - '& Button': { - py: 1.25, - px: 2.5, - border: 'none', - borderRadius: '4px !important', - fontSize: '0.875rem', - fontWeight: 700, - letterSpacing: '0.02rem', - textAlign: 'left', - justifyContent: 'flex-start' - } - }}> - {views.map((view) => ( - } - key={view.value} - value={view.value} - color="primary"> - {view.label} - - ))} - - - ); -}; diff --git a/app/src/features/standards/view/components/AccordionStandardCard.tsx b/app/src/features/standards/view/components/AccordionStandardCard.tsx index 2513448d24..4966a82f4b 100644 --- a/app/src/features/standards/view/components/AccordionStandardCard.tsx +++ b/app/src/features/standards/view/components/AccordionStandardCard.tsx @@ -1,26 +1,27 @@ import { mdiChevronDown, mdiChevronUp } from '@mdi/js'; import { Icon } from '@mdi/react'; import { Collapse } from '@mui/material'; -import Box, { BoxProps } from '@mui/material/Box'; -import Paper from '@mui/material/Paper'; +import Box from '@mui/material/Box'; +import Paper, { PaperProps } from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { PropsWithChildren, useState } from 'react'; +import React, { PropsWithChildren, ReactElement, useState } from 'react'; -interface IAccordionStandardCardProps extends BoxProps { - label: string; +interface IAccordionStandardCardProps extends PaperProps { + label: string | React.ReactElement; subtitle?: string | null; - ornament?: JSX.Element; + ornament?: ReactElement; colour: string; disableCollapse?: boolean; } /** * Returns a collapsible paper component for displaying lookup values - * @param props - * @returns + * + * @param {PropsWithChildren} props + * @return {*} */ export const AccordionStandardCard = (props: PropsWithChildren) => { - const { label, subtitle, children, colour, ornament, disableCollapse } = props; + const { label, subtitle, children, colour, ornament, disableCollapse, ...paperProps } = props; const [isCollapsed, setIsCollapsed] = useState(true); @@ -33,27 +34,26 @@ export const AccordionStandardCard = (props: PropsWithChildren + - - - {label} - + + {label} + + {ornament} + {expandable && } - {expandable && } diff --git a/app/src/features/standards/view/species/SpeciesStandardsResults.tsx b/app/src/features/standards/view/species/SpeciesStandardsResults.tsx index 859ae312bb..d665721a6f 100644 --- a/app/src/features/standards/view/species/SpeciesStandardsResults.tsx +++ b/app/src/features/standards/view/species/SpeciesStandardsResults.tsx @@ -2,11 +2,16 @@ import { mdiRuler, mdiTag } from '@mdi/js'; import { Box, Divider, Stack, Typography } from '@mui/material'; import { blueGrey, grey } from '@mui/material/colors'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; import { ISpeciesStandards } from 'interfaces/useStandardsApi.interface'; import { useState } from 'react'; -import SpeciesStandardsToolbar, { SpeciesStandardsViewEnum } from './components/SpeciesStandardsToolbar'; + +enum SpeciesStandardsViewEnum { + MEASUREMENTS = 'measurements', + MARKING_BODY_LOCATIONS = 'marking_body_locations' +} interface ISpeciesStandardsResultsProps { data?: ISpeciesStandards; @@ -35,23 +40,14 @@ const SpeciesStandardsResults = (props: ISpeciesStandardsResultsProps) => { - diff --git a/app/src/features/standards/view/species/components/SpeciesStandardsToolbar.tsx b/app/src/features/standards/view/species/components/SpeciesStandardsToolbar.tsx deleted file mode 100644 index feeca668c7..0000000000 --- a/app/src/features/standards/view/species/components/SpeciesStandardsToolbar.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import Icon from '@mdi/react'; -import { Box, Button, ToggleButton, ToggleButtonGroup } from '@mui/material'; - -export enum SpeciesStandardsViewEnum { - MEASUREMENTS = 'MEASUREMENTS', - MARKING_BODY_LOCATIONS = 'MARKING BODY LOCATIONS' -} - -interface ISurveySpatialDatasetView { - label: string; - icon: string; - value: SpeciesStandardsViewEnum; - isLoading: boolean; -} - -interface ISpeciesStandardsToolbarProps { - updateDatasetView: (view: SpeciesStandardsViewEnum) => void; - views: ISurveySpatialDatasetView[]; - activeView: SpeciesStandardsViewEnum; -} - -/** - * Toolbar for handling what species standards information is displayed - * - * @return {*} - */ -const SpeciesStandardsToolbar = (props: ISpeciesStandardsToolbarProps) => { - const updateDatasetView = (_event: React.MouseEvent, view: SpeciesStandardsViewEnum) => { - if (!view) { - return; - } - - props.updateDatasetView(view); - }; - - return ( - - - {props.views.map((view) => ( - } - value={view.value}> - {view.label} - - ))} - - - ); -}; - -export default SpeciesStandardsToolbar; diff --git a/app/src/features/summary/SummaryPage.tsx b/app/src/features/summary/SummaryPage.tsx index b70623d604..39d50016db 100644 --- a/app/src/features/summary/SummaryPage.tsx +++ b/app/src/features/summary/SummaryPage.tsx @@ -6,8 +6,10 @@ import Paper from '@mui/material/Paper'; import PageHeader from 'components/layout/PageHeader'; import { SystemRoleGuard } from 'components/security/Guards'; import { SYSTEM_ROLE } from 'constants/roles'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { ListDataTableContainer } from 'features/summary/list-data/ListDataTableContainer'; import { TabularDataTableContainer } from 'features/summary/tabular-data/TabularDataTableContainer'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { Link as RouterLink } from 'react-router-dom'; /** @@ -36,6 +38,8 @@ const SummaryPage = () => { /> + + diff --git a/app/src/features/summary/list-data/ListDataTableContainer.tsx b/app/src/features/summary/list-data/ListDataTableContainer.tsx index 109bfad200..40cf426227 100644 --- a/app/src/features/summary/list-data/ListDataTableContainer.tsx +++ b/app/src/features/summary/list-data/ListDataTableContainer.tsx @@ -2,12 +2,14 @@ import { mdiFolder, mdiListBoxOutline, mdiMagnify } from '@mdi/js'; import Icon from '@mdi/react'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; import ProjectsListContainer from 'features/summary/list-data/project/ProjectsListContainer'; import SurveysListContainer from 'features/summary/list-data/survey/SurveysListContainer'; import { useSearchParams } from 'hooks/useSearchParams'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { useState } from 'react'; export const ACTIVE_VIEW_KEY = 'lvk'; @@ -28,16 +30,6 @@ type ListDataTableURLParams = { [SHOW_SEARCH_KEY]: SHOW_SEARCH_VALUE; }; -const buttonSx = { - py: 0.5, - px: 1.5, - border: 'none !important', - fontWeight: 700, - borderRadius: '4px !important', - fontSize: '0.875rem', - letterSpacing: '0.02rem' -}; - /** * Data table component for list data (ie: projects, surveys). * @@ -46,7 +38,9 @@ const buttonSx = { export const ListDataTableContainer = () => { const { searchParams, setSearchParams } = useSearchParams(); - const [activeView, setActiveView] = useState(searchParams.get(ACTIVE_VIEW_KEY) ?? ACTIVE_VIEW_VALUE.projects); + const [activeView, setActiveView] = useState( + (searchParams.get(ACTIVE_VIEW_KEY) as ACTIVE_VIEW_VALUE | null) ?? ACTIVE_VIEW_VALUE.projects + ); const [showSearch, setShowSearch] = useState(searchParams.get(SHOW_SEARCH_KEY) === SHOW_SEARCH_VALUE.true); const views = [ @@ -54,53 +48,34 @@ export const ListDataTableContainer = () => { { value: ACTIVE_VIEW_VALUE.surveys, label: 'surveys', icon: mdiListBoxOutline } ]; - const onChangeView = (_: React.MouseEvent, value: ACTIVE_VIEW_VALUE) => { - if (!value) { - // User has clicked the active view, do nothing - return; - } - - setSearchParams(searchParams.set(ACTIVE_VIEW_KEY, value)); - setActiveView(value); - }; - return ( <> - - {views.map((view) => ( - } - value={view.value}> - {view.label} - - ))} - - - + orientation="horizontal" + /> + + + + {activeView === ACTIVE_VIEW_VALUE.projects && } diff --git a/app/src/features/summary/list-data/project/ProjectsListContainer.tsx b/app/src/features/summary/list-data/project/ProjectsListContainer.tsx index fb46c2b153..f213e2ad3d 100644 --- a/app/src/features/summary/list-data/project/ProjectsListContainer.tsx +++ b/app/src/features/summary/list-data/project/ProjectsListContainer.tsx @@ -117,8 +117,8 @@ const ProjectsListContainer = (props: IProjectsListContainerProps) => { { field: 'project_id', headerName: 'ID', - width: 70, - minWidth: 70, + width: 85, + minWidth: 85, renderHeader: () => ( ID @@ -210,7 +210,7 @@ const ProjectsListContainer = (props: IProjectsListContainerProps) => { - + } diff --git a/app/src/features/summary/list-data/survey/SurveysListContainer.tsx b/app/src/features/summary/list-data/survey/SurveysListContainer.tsx index 5226ea5e04..18b2b003e6 100644 --- a/app/src/features/summary/list-data/survey/SurveysListContainer.tsx +++ b/app/src/features/summary/list-data/survey/SurveysListContainer.tsx @@ -114,8 +114,8 @@ const SurveysListContainer = (props: ISurveysListContainerProps) => { { field: 'survey_id', headerName: 'ID', - width: 70, - minWidth: 70, + width: 85, + minWidth: 85, renderHeader: () => ( ID @@ -217,7 +217,7 @@ const SurveysListContainer = (props: ISurveysListContainerProps) => { - + } diff --git a/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx b/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx index 69dde92d3d..5a03ef44f8 100644 --- a/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx +++ b/app/src/features/summary/tabular-data/TabularDataTableContainer.tsx @@ -1,16 +1,15 @@ import { mdiEye, mdiPaw, mdiWifiMarker } from '@mdi/js'; -import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import Stack from '@mui/material/Stack'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import Typography from '@mui/material/Typography'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; import AnimalsListContainer from 'features/summary/tabular-data/animal/AnimalsListContainer'; import ObservationsListContainer from 'features/summary/tabular-data/observation/ObservationsListContainer'; import TelemetryListContainer from 'features/summary/tabular-data/telemetry/TelemetryListContainer'; import { useSearchParams } from 'hooks/useSearchParams'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { useState } from 'react'; export const ACTIVE_VIEW_KEY = 'tavk'; @@ -32,18 +31,6 @@ type TabularDataTableURLParams = { [SHOW_SEARCH_KEY]: SHOW_SEARCH_VALUE; }; -const buttonSx = { - py: 0.5, - px: 2, - border: 'none', - fontWeight: 700, - borderRadius: '4px !important', - fontSize: '0.875rem', - letterSpacing: '0.02rem', - minHeight: '35px', - justifyContent: 'flex-start' -}; - /** * Data table component for tabular data (ie: observations, animals, telemetry). * @@ -52,7 +39,9 @@ const buttonSx = { export const TabularDataTableContainer = () => { const { searchParams, setSearchParams } = useSearchParams(); - const [activeView, setActiveView] = useState(searchParams.get(ACTIVE_VIEW_KEY) ?? ACTIVE_VIEW_VALUE.observations); + const [activeView, setActiveView] = useState( + (searchParams.get(ACTIVE_VIEW_KEY) as ACTIVE_VIEW_VALUE | null) ?? ACTIVE_VIEW_VALUE.observations + ); const showSearch = true; const views = [ @@ -61,43 +50,26 @@ export const TabularDataTableContainer = () => { { value: ACTIVE_VIEW_VALUE.telemetry, label: 'telemetry', icon: mdiWifiMarker } ]; - const onChangeView = (_: React.MouseEvent, value: ACTIVE_VIEW_VALUE) => { - if (!value) { - // User has clicked the active view, do nothing - return; - } - - setSearchParams(searchParams.set(ACTIVE_VIEW_KEY, value)); - setActiveView(value); - }; - return ( - - Data - {views.map((view) => ( - } - value={view.value}> - {view.label} - - ))} - + + + Data + + + { + setSearchParams(searchParams.set(ACTIVE_VIEW_KEY, view)); + setActiveView(view); + }} + orientation="vertical" + /> + + + {activeView === ACTIVE_VIEW_VALUE.observations && } {activeView === ACTIVE_VIEW_VALUE.animals && } diff --git a/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx b/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx index dcafbcd54d..31964d4a2d 100644 --- a/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx +++ b/app/src/features/summary/tabular-data/animal/AnimalsListContainer.tsx @@ -101,8 +101,8 @@ const AnimalsListContainer = (props: IAnimalsListContainerProps) => { { field: 'critter_id', headerName: 'ID', - width: 70, - minWidth: 70, + width: 85, + minWidth: 85, sortable: false, renderHeader: () => ( @@ -157,7 +157,7 @@ const AnimalsListContainer = (props: IAnimalsListContainerProps) => { - + } diff --git a/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx b/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx index 5f43fff388..da84ef1d9e 100644 --- a/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx +++ b/app/src/features/summary/tabular-data/observation/ObservationsListContainer.tsx @@ -167,8 +167,8 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { { field: 'survey_observation_id', headerName: 'ID', - width: 70, - minWidth: 70, + width: 85, + minWidth: 85, renderHeader: () => ( ID @@ -246,7 +246,7 @@ const ObservationsListContainer = (props: IObservationsListContainerProps) => { - + } diff --git a/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx b/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx index 1609839ea1..e640b3512c 100644 --- a/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx +++ b/app/src/features/summary/tabular-data/telemetry/TelemetryListContainer.tsx @@ -160,7 +160,7 @@ const TelemetryListContainer = (props: IAllTelemetryListContainerProps) => { - + } diff --git a/app/src/features/surveys/animals/AnimalHeader.tsx b/app/src/features/surveys/animals/AnimalHeader.tsx index 5ea0cc4c52..fc93d4771a 100644 --- a/app/src/features/surveys/animals/AnimalHeader.tsx +++ b/app/src/features/surveys/animals/AnimalHeader.tsx @@ -1,6 +1,7 @@ +import { mdiEye, mdiWifiMarker } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; @@ -19,6 +20,20 @@ export interface IAnimalHeaderProps { */ export const AnimalHeader = (props: IAnimalHeaderProps) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Telemetry', + to: `/admin/projects/${project_id}/surveys/${survey_id}/telemetry/details`, + icon: mdiWifiMarker + }, + { + label: 'Observations', + to: `/admin/projects/${project_id}/surveys/${survey_id}/observations`, + icon: mdiEye + } + ]; + return ( { to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Animals - + Animals } /> diff --git a/app/src/features/surveys/animals/AnimalPage.tsx b/app/src/features/surveys/animals/AnimalPage.tsx index 07058fdb2d..b8418906ad 100644 --- a/app/src/features/surveys/animals/AnimalPage.tsx +++ b/app/src/features/surveys/animals/AnimalPage.tsx @@ -1,9 +1,11 @@ import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import Box from '@mui/system/Box'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useAnimalPageContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useEffect } from 'react'; import { AnimalHeader } from './AnimalHeader'; import { AnimalListContainer } from './list/AnimalListContainer'; @@ -56,6 +58,7 @@ export const SurveyAnimalPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> + { {values.ecological_units.map((ecological_unit, index) => ( - unit.collection_category_id)} - ecologicalUnits={ecologicalUnitsDataLoader?.data ?? []} - arrayHelpers={arrayHelpers} - index={index} + unit.collection_category_id)} + onDelete={() => arrayHelpers.remove(index)} /> diff --git a/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx index 5e9f2d0c0a..744b7956db 100644 --- a/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx +++ b/app/src/features/surveys/animals/profile/captures/AnimalCaptureContainer.tsx @@ -8,7 +8,9 @@ import Typography from '@mui/material/Typography'; import { SkeletonHorizontalStack } from 'components/loading/SkeletonLoaders'; import { AnimalCaptureCardContainer } from 'features/surveys/animals/profile/captures/components/AnimalCaptureCardContainer'; import { AnimalCapturesToolbar } from 'features/surveys/animals/profile/captures/components/AnimalCapturesToolbar'; -import { useAnimalPageContext, useSurveyContext } from 'hooks/useContext'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useAnimalPageContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import { ICaptureResponse, @@ -31,6 +33,8 @@ export interface ICaptureWithSupplementaryData extends ICaptureResponse { */ export const AnimalCaptureContainer = () => { const critterbaseApi = useCritterbaseApi(); + const biohubApi = useBiohubApi(); + const dialogContext = useDialogContext(); const history = useHistory(); @@ -73,36 +77,73 @@ export const AnimalCaptureContainer = () => { })) || []; const handleDelete = async (selectedCapture: string, critter_id: number) => { - // Delete markings and measurements associated with the capture to avoid foreign key constraint error - await critterbaseApi.critters.bulkUpdate({ - markings: data?.markings - .filter((marking) => marking.capture_id === selectedCapture) - .map((marking) => ({ - ...marking, - critter_id: selectedAnimal.critterbase_critter_id, - _delete: true - })), - qualitative_measurements: - data?.measurements.qualitative - .filter((measurement) => measurement.capture_id === selectedCapture) - .map((measurement) => ({ - ...measurement, + try { + // Delete markings and measurements associated with the capture to avoid foreign key constraint error + await critterbaseApi.critters.bulkUpdate({ + markings: data?.markings + .filter((marking) => marking.capture_id === selectedCapture) + .map((marking) => ({ + ...marking, + critter_id: selectedAnimal.critterbase_critter_id, _delete: true - })) ?? [], - quantitative_measurements: - data?.measurements.quantitative - .filter((measurement) => measurement.capture_id === selectedCapture) - .map((measurement) => ({ - ...measurement, - _delete: true - })) ?? [] - }); + })), + qualitative_measurements: + data?.measurements.qualitative + .filter((measurement) => measurement.capture_id === selectedCapture) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [], + quantitative_measurements: + data?.measurements.quantitative + .filter((measurement) => measurement.capture_id === selectedCapture) + .map((measurement) => ({ + ...measurement, + _delete: true + })) ?? [] + }); + + // Delete the actual capture + await critterbaseApi.capture.deleteCapture(selectedCapture); + + // Delete all capture attachments + await biohubApi.animal.deleteCaptureAttachments({ + projectId, + surveyId, + critterId: selectedAnimal.critter_id, + critterbaseCaptureId: selectedCapture + }); - // Delete the actual capture - await critterbaseApi.capture.deleteCapture(selectedCapture); + // Refresh capture container + animalPageContext.critterDataLoader.refresh(projectId, surveyId, critter_id); + + // Show success snackbar + dialogContext.setSnackbar({ + open: true, + onClose: () => dialogContext.setSnackbar({ open: false }), + snackbarMessage: ( + + Successfully deleted Capture + + ) + }); + } catch (error) { + const apiError = error as APIError; - // Refresh capture container - animalPageContext.critterDataLoader.refresh(projectId, surveyId, critter_id); + dialogContext.setErrorDialog({ + open: true, + dialogTitle: 'Error deleting Capture', + dialogText: 'An error occurred while deleting the Capture.', + dialogError: apiError.message, + dialogErrorDetails: apiError.errors, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + } }; const capturesWithLocation = captures.filter((capture) => capture.capture_location); diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx index ea1baae3ce..580cbaf342 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm.tsx @@ -1,9 +1,18 @@ import { Divider } from '@mui/material'; import Stack from '@mui/material/Stack'; import FormikErrorSnackbar from 'components/alert/FormikErrorSnackbar'; +import { AttachmentTableDropzone } from 'components/attachments/AttachmentTableDropzone'; import HorizontalSplitFormComponent from 'components/fields/HorizontalSplitFormComponent'; import { Formik, FormikProps } from 'formik'; -import { ICreateCaptureRequest, IEditCaptureRequest } from 'interfaces/useCritterApi.interface'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import { useS3FileDownload } from 'hooks/useS3Download'; +import { + ICreateCaptureRequest, + ICritterCaptureAttachment, + IEditCaptureRequest +} from 'interfaces/useCritterApi.interface'; +import { useState } from 'react'; import { isDefined } from 'utils/Utils'; import yup from 'utils/YupSchema'; import { MarkingsForm } from '../../../markings/MarkingsForm'; @@ -12,10 +21,13 @@ import { CaptureGeneralInformationForm } from './general-information/CaptureGene import { CaptureLocationForm } from './location/CaptureLocationForm'; import { ReleaseLocationForm } from './location/ReleaseLocationForm'; +const CRITTER_CAPTURE_ATTACHMENT_TYPE = 'Capture'; + export interface IAnimalCaptureFormProps { initialCaptureData: FormikValuesType; handleSubmit: (formikData: FormikValuesType) => void; formikRef: React.RefObject>; + captureAttachments?: ICritterCaptureAttachment[]; } /** @@ -28,9 +40,23 @@ export interface IAnimalCaptureFormProps( props: IAnimalCaptureFormProps ) => { + const biohubApi = useBiohubApi(); + const { projectId, surveyId } = useSurveyContext(); + + const { downloadS3File } = useS3FileDownload(); + + // Track the capture attachment ids that are flagged for deletion (formik ref is not stateful) + const [captureDeleteIds, setCaptureDeleteIds] = useState([]); + const animalCaptureYupSchema = yup.object({ + attachments: yup.object({ + capture_attachments: yup.object({ + create: yup.mixed(), + delete: yup.array().of(yup.number()) + }) + }), capture: yup.object({ - capture_id: yup.string().nullable(), + capture_id: yup.string(), capture_date: yup.string().required('Capture date is required'), capture_time: yup.string().nullable(), capture_comment: yup.string().required('Capture comment is required'), @@ -136,6 +162,47 @@ export const AnimalCaptureForm = { + if (!file) return; + + props.formikRef.current?.setFieldValue(`attachments.capture_attachments.create['${file.name}']`, file); + }; + + /** + * Remove a staged file. (Create) + + * @param {string} fileName + * @return {void} + */ + const removeStagedFile = (fileName: string) => { + props.formikRef.current?.setFieldValue(`attachments.capture_attachments.create['${fileName}']`, undefined); + }; + + /** + * Flag uploaded file for delete. (Edit) + * + * @param {number} attachmentId + * @return {void} + */ + const flagUploadedFileForDelete = (attachmentId: number) => { + // If the attachment is not already flagged for deletion, add it to the list + if (!captureDeleteIds.includes(attachmentId)) { + const newDeleteIds = [...captureDeleteIds, attachmentId]; + + // Update the state of the deleted ids + setCaptureDeleteIds(newDeleteIds); + + // Update the formik field + props.formikRef.current?.setFieldValue(`attachments.capture_attachments.delete`, newDeleteIds); + } + }; + return ( } /> + ({ + id: attachment.critter_capture_attachment_id, + s3Key: attachment.key, + name: attachment.file_name ?? 'Unknown', + size: attachment.file_size, + type: attachment.file_type + })) + // Filter out attachments that are flagged for deletion + .filter((attachment) => !captureDeleteIds.includes(attachment.id))} + onStagedAttachment={addStagedFile} + onRemoveStagedAttachment={removeStagedFile} + onRemoveUploadedAttachment={flagUploadedFileForDelete} + onDownloadAttachment={(id) => + downloadS3File( + biohubApi.survey.getSurveyAttachmentSignedURL( + projectId, + surveyId, + id, + CRITTER_CAPTURE_ATTACHMENT_TYPE + ) + ) + } + /> + } + /> + { const history = useHistory(); + const biohubApi = useBiohubApi(); const critterbaseApi = useCritterbaseApi(); const surveyContext = useSurveyContext(); @@ -106,68 +114,69 @@ export const CreateCapturePage = () => { setIsSaving(true); try { - const surveyCritterId = animalPageContext.selectedAnimal?.critter_id; - const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + const surveyCritterId = Number(animalPageContext.selectedAnimal?.critter_id); + const critterbaseCritterId = String(animalPageContext.selectedAnimal?.critterbase_critter_id); + const critterbaseCaptureId = v4(); // Generate a static UUID for the capture and attachments + const captureAttachments = Object.values(values.attachments.capture_attachments.create); if (!values || !critterbaseCritterId || values.capture.capture_location?.geometry.type !== 'Point') { return; } - const captureLocation = { - longitude: values.capture.capture_location.geometry.coordinates[0], - latitude: values.capture.capture_location.geometry.coordinates[1], - coordinate_uncertainty: 0, - coordinate_uncertainty_units: 'm' - }; - - // if release location is null, use the capture location, otherwise format it for critterbase - const releaseLocation = - values.capture.release_location?.geometry?.type === 'Point' - ? { - longitude: values.capture.release_location.geometry.coordinates[0], - latitude: values.capture.release_location.geometry.coordinates[1], - coordinate_uncertainty: 0, - coordinate_uncertainty_units: 'm' - } - : captureLocation; - - // Must create capture first to avoid foreign key constraints. Can't guarantee that the capture is - // inserted before the measurements/markings. - const captureResponse = await critterbaseApi.capture.createCapture({ - critter_id: critterbaseCritterId, - capture_id: undefined, - capture_date: values.capture.capture_date, - capture_time: values.capture.capture_time || undefined, - release_date: values.capture.release_date || values.capture.capture_date, - release_time: values.capture.release_time || values.capture.capture_time || undefined, - capture_comment: values.capture.capture_comment || undefined, - release_comment: values.capture.release_comment || undefined, - capture_location: captureLocation, - release_location: releaseLocation ?? captureLocation - }); + const captureLocations: ILocationCreate[] = [ + { + location_id: v4(), + longitude: values.capture.capture_location.geometry.coordinates[0], + latitude: values.capture.capture_location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_unit: 'm' + } + ]; - if (!captureResponse) { - showCreateErrorDialog({ - dialogError: 'An error occurred while attempting to create the capture record.', - dialogErrorDetails: ['Capture create failed'] + if (values.capture.release_location?.geometry.type === 'Point') { + captureLocations.push({ + location_id: v4(), + longitude: values.capture.release_location.geometry.coordinates[0], + latitude: values.capture.release_location.geometry.coordinates[1], + coordinate_uncertainty: 0, + coordinate_uncertainty_unit: 'm' }); - return; } - // Create new measurements added while editing the capture + /** + * Create the Capture, Markings, and Measurements and Locations in Critterbase. + * + * Note: Critterbase will add the data in the correct order to prevent foreign key constraints. + */ const bulkResponse = await critterbaseApi.critters.bulkCreate({ + locations: captureLocations, + captures: [ + { + critter_id: critterbaseCritterId, + capture_id: critterbaseCaptureId, + capture_date: values.capture.capture_date, + capture_time: values.capture.capture_time || undefined, + release_date: values.capture.release_date || values.capture.capture_date, + release_time: values.capture.release_time || values.capture.capture_time || undefined, + capture_comment: values.capture.capture_comment || undefined, + release_comment: values.capture.release_comment || undefined, + capture_location_id: captureLocations[0].location_id, + release_location_id: + captureLocations.length > 1 ? captureLocations[1].location_id : captureLocations[0].location_id + } + ], markings: values.markings.map((marking) => ({ ...marking, marking_id: marking.marking_id, critter_id: critterbaseCritterId, - capture_id: captureResponse.capture_id + capture_id: critterbaseCaptureId })), qualitative_measurements: values.measurements .filter(isQualitativeMeasurementCreate) // Format qualitative measurements for create .map((measurement) => ({ critter_id: critterbaseCritterId, - capture_id: captureResponse.capture_id, + capture_id: critterbaseCaptureId, taxon_measurement_id: measurement.taxon_measurement_id, qualitative_option_id: measurement.qualitative_option_id })), @@ -176,7 +185,7 @@ export const CreateCapturePage = () => { // Format quantitative measurements for create .map((measurement) => ({ critter_id: critterbaseCritterId, - capture_id: captureResponse.capture_id, + capture_id: critterbaseCaptureId, taxon_measurement_id: measurement.taxon_measurement_id, value: measurement.value })) @@ -190,8 +199,25 @@ export const CreateCapturePage = () => { return; } - // Refresh page + // Upload Capture attachments + if (captureAttachments.length) { + await biohubApi.animal + .uploadCritterCaptureAttachments({ + projectId, + surveyId, + critterId: surveyCritterId, + critterbaseCaptureId: critterbaseCaptureId, + files: captureAttachments + }) + .catch(() => { + showCreateErrorDialog({ + dialogError: 'Failed to upload capture attachments', + dialogErrorDetails: ['Failed to upload capture attachments'] + }); + }); + } + // Refresh page if (surveyCritterId) { animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); } @@ -200,7 +226,7 @@ export const CreateCapturePage = () => { } catch (error) { const apiError = error as APIError; showCreateErrorDialog({ - dialogTitle: 'Error Creating Survey', + dialogTitle: CreateCaptureI18N.createErrorTitle, dialogError: apiError?.message, dialogErrorDetails: apiError?.errors }); diff --git a/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx index 688e0dcf9a..e62850a12f 100644 --- a/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx +++ b/app/src/features/surveys/animals/profile/captures/capture-form/edit/EditCapturePage.tsx @@ -13,6 +13,7 @@ import { EditCaptureI18N } from 'constants/i18n'; import { AnimalCaptureForm } from 'features/surveys/animals/profile/captures/capture-form/components/AnimalCaptureForm'; import { FormikProps } from 'formik'; import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useAnimalPageContext, useDialogContext, useProjectContext, useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; import useDataLoader from 'hooks/useDataLoader'; @@ -32,6 +33,7 @@ export const EditCapturePage = () => { const history = useHistory(); const critterbaseApi = useCritterbaseApi(); + const biohubApi = useBiohubApi(); const surveyContext = useSurveyContext(); const projectContext = useProjectContext(); @@ -51,13 +53,25 @@ export const EditCapturePage = () => { const { projectId, surveyId } = surveyContext; + useEffect(() => { + if (!surveyCritterId) { + return; + } + + animalPageContext.critterDataLoader.load(projectId, surveyId, surveyCritterId); + }, [animalPageContext.critterDataLoader, projectId, surveyId, surveyCritterId]); + const critter = animalPageContext.critterDataLoader.data; - const captureDataLoader = useDataLoader(() => critterbaseApi.capture.getCapture(captureId)); + const captureDataLoader = useDataLoader((captureId: string) => critterbaseApi.capture.getCapture(captureId)); useEffect(() => { - captureDataLoader.load(); - }, [captureDataLoader]); + if (!captureId) { + return; + } + + captureDataLoader.load(captureId); + }, [captureDataLoader, captureId]); const capture = captureDataLoader.data; @@ -88,7 +102,11 @@ export const EditCapturePage = () => { setIsSaving(true); try { + const surveyCritterId = Number(animalPageContext.selectedAnimal?.critter_id); const critterbaseCritterId = animalPageContext.selectedAnimal?.critterbase_critter_id; + const critterbaseCaptureId = capture.capture_id; + const captureAttachments = Object.values(values.attachments.capture_attachments.create); + const captureAttachmentsToDelete = values.attachments.capture_attachments.delete; if (!values || !critterbaseCritterId || values.capture.capture_location?.geometry.type !== 'Point') { return; @@ -163,6 +181,32 @@ export const EditCapturePage = () => { return; } + // Upload Capture attachments and delete any marked for deletion + if (captureAttachments.length || captureAttachmentsToDelete.length) { + await biohubApi.animal + .uploadCritterCaptureAttachments({ + projectId, + surveyId, + critterId: surveyCritterId, + critterbaseCaptureId: critterbaseCaptureId, + files: captureAttachments, + deleteIds: captureAttachmentsToDelete + }) + .catch(() => { + dialogContext.setErrorDialog({ + dialogTitle: 'Failed to modify capture attachments.', + dialogText: EditCaptureI18N.createErrorText, + open: true, + onClose: () => { + dialogContext.setErrorDialog({ open: false }); + }, + onOk: () => { + dialogContext.setErrorDialog({ open: false }); + } + }); + }); + } + // Refresh page if (surveyCritterId) { animalPageContext.critterDataLoader.refresh(projectId, surveyId, surveyCritterId); @@ -191,6 +235,12 @@ export const EditCapturePage = () => { // Initial formik values const initialFormikValues: IEditCaptureRequest = { + attachments: { + capture_attachments: { + create: {}, + delete: [] + } + }, capture: { capture_id: capture.capture_id, capture_method_id: capture.capture_method_id ?? '', @@ -313,6 +363,9 @@ export const EditCapturePage = () => { initialCaptureData={initialFormikValues} handleSubmit={(formikData) => handleSubmit(formikData)} formikRef={formikRef} + captureAttachments={critter.attachments.capture_attachments.filter( + (attachment) => attachment.critterbase_capture_id === capture.capture_id + )} /> + to={`/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critter_id}/capture/${selectedCapture}/edit`}> diff --git a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx index a5a8d1e6ad..a9add54457 100644 --- a/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx +++ b/app/src/features/surveys/animals/profile/mortality/components/AnimalMortalityCardContainer.tsx @@ -76,7 +76,7 @@ export const AnimalMortalityCardContainer = (props: IAnimalMortalityCardContaine } }}> + to={`/admin/projects/${projectId}/surveys/${surveyId}/animals/${selectedAnimal.critter_id}/mortality/${selectedMortality}/edit`}> diff --git a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx index 737235ba72..50216895d8 100644 --- a/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx +++ b/app/src/features/surveys/animals/profile/mortality/mortality-form/edit/EditMortalityPage.tsx @@ -52,13 +52,26 @@ export const EditMortalityPage = () => { const { projectId, surveyId } = surveyContext; + useEffect(() => { + if (!surveyCritterId) { + return; + } + + animalPageContext.critterDataLoader.load(projectId, surveyId, surveyCritterId); + }, [animalPageContext.critterDataLoader, projectId, surveyCritterId, surveyId]); + const critter = animalPageContext.critterDataLoader.data; - const mortalityDataLoader = useDataLoader(() => critterbaseApi.mortality.getMortality(mortalityId)); + const mortalityDataLoader = useDataLoader((mortalityId: string) => + critterbaseApi.mortality.getMortality(mortalityId) + ); useEffect(() => { - mortalityDataLoader.load(); - }, [mortalityDataLoader]); + if (!mortalityId) { + return; + } + mortalityDataLoader.load(mortalityId); + }, [mortalityDataLoader, mortalityId]); const mortality = mortalityDataLoader.data; diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx index b6e0fa47ba..a7cd24123b 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/CreateSurveyBlockDialog.tsx @@ -21,6 +21,17 @@ const CreateSurveyBlockDialog: React.FC = (props) => { survey_block_id: null, name: '', description: '', + geojson: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0] + }, + properties: { + name: 'Sample', + description: 'This is a placeholder.' + } + }, sample_block_count: 0 }, validationSchema: BlockCreateYupSchema diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx index 960e881d35..b91ae82a53 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/EditSurveyBlockDialog.tsx @@ -1,10 +1,10 @@ import EditDialog from 'components/dialog/EditDialog'; import BlockForm from './BlockForm'; -import { BlockEditYupSchema, ISurveyBlock } from './SurveyBlockForm'; +import { BlockEditYupSchema, IPostSurveyBlock } from './SurveyBlockForm'; interface IEditBlockProps { open: boolean; - initialData?: ISurveyBlock; + initialData?: IPostSurveyBlock; onSave: (data: any, index?: number) => void; onClose: () => void; } @@ -23,6 +23,7 @@ const EditSurveyBlockDialog: React.FC = (props) => { survey_block_id: initialData?.block.survey_block_id || null, name: initialData?.block.name || '', description: initialData?.block.description || '', + geojson: initialData?.block.geojson || '', sample_block_count: initialData?.block.sample_block_count }, validationSchema: BlockEditYupSchema diff --git a/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx index 98779ebf7f..6c4c893d1e 100644 --- a/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx +++ b/app/src/features/surveys/components/sampling-strategy/blocks/SurveyBlockForm.tsx @@ -12,6 +12,7 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import YesNoDialog from 'components/dialog/YesNoDialog'; import { useFormikContext } from 'formik'; +import { Feature } from 'geojson'; import { IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; import React, { useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; @@ -28,6 +29,7 @@ export const SurveyBlockInitialValues = { export const BlockCreateYupSchema = yup.object({ name: yup.string().required('Name is required').max(50, 'Maximum 50 characters'), description: yup.string().required('Description is required').max(250, 'Maximum 250 characters') + // TODO: Include geojson in validation after adding map control for blocks }); // Form validation for Block Item @@ -35,12 +37,13 @@ export const BlockEditYupSchema = BlockCreateYupSchema.shape({ sample_block_count: yup.number().required('Sample block count is required.') }); -export interface ISurveyBlock { +export interface IPostSurveyBlock { index: number; block: { survey_block_id: number | null; name: string; description: string; + geojson: Feature; sample_block_count: number; }; } @@ -50,7 +53,7 @@ const SurveyBlockForm: React.FC = () => { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isYesNoDialogOpen, setIsYesNoDialogOpen] = useState(false); const [anchorEl, setAnchorEl] = useState(null); - const [editData, setEditData] = useState(undefined); + const [editData, setEditData] = useState(undefined); const formikProps = useFormikContext(); const { values, handleSubmit, setFieldValue } = formikProps; diff --git a/app/src/features/surveys/components/species/SpeciesForm.tsx b/app/src/features/surveys/components/species/SpeciesForm.tsx index a4fab7b867..2eb2b53f86 100644 --- a/app/src/features/surveys/components/species/SpeciesForm.tsx +++ b/app/src/features/surveys/components/species/SpeciesForm.tsx @@ -46,24 +46,6 @@ export const SpeciesYupSchema = yup.object().shape({ ) .min(1, 'You must specify a focal species') .required('Required') - .test('is-unique-ecological-unit', 'Ecological units must be unique', function () { - const focalSpecies = (this.options.context?.species.focal_species ?? []) as ITaxonomyWithEcologicalUnits[]; - - const seenCollectionUnitIts = new Set(); - - for (const focalSpeciesItem of focalSpecies) { - for (const ecologicalUnit of focalSpeciesItem.ecological_units) { - if (seenCollectionUnitIts.has(ecologicalUnit.critterbase_collection_category_id)) { - // Duplicate ecological collection category id found, return false - return false; - } - seenCollectionUnitIts.add(ecologicalUnit.critterbase_collection_category_id); - } - } - - // Valid, return true - return true; - }) }) }); diff --git a/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx b/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx index d877370212..77294508fe 100644 --- a/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx +++ b/app/src/features/surveys/components/species/components/FocalSpeciesEcologicalUnitsForm.tsx @@ -3,7 +3,7 @@ import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; -import { EcologicalUnitsSelect } from 'components/species/ecological-units/EcologicalUnitsSelect'; +import { EcologicalUnitDualSelect } from 'components/species/ecological-units/EcologicalUnitDualSelect'; import { initialEcologicalUnitValues } from 'features/surveys/animals/animal-form/components/ecological-units/EcologicalUnitsForm'; import { FieldArray, FieldArrayRenderProps, useFormikContext } from 'formik'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; @@ -11,8 +11,6 @@ import useDataLoader from 'hooks/useDataLoader'; import { ICreateSurveyRequest, IEditSurveyRequest } from 'interfaces/useSurveyApi.interface'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import { useEffect } from 'react'; -import { isDefined } from 'utils/Utils'; -import { v4 } from 'uuid'; export interface ISelectedSpeciesProps { /** @@ -61,17 +59,17 @@ export const FocalSpeciesEcologicalUnitsForm = (props: ISelectedSpeciesProps) => name={`species.focal_species.[${index}].ecological_units`} render={(arrayHelpers: FieldArrayRenderProps) => ( - {selectedUnits.map((ecological_unit, ecologicalUnitIndex) => ( - unit.critterbase_collection_category_id) - .filter(isDefined)} - ecologicalUnits={ecologicalUnitsForSpecies} - arrayHelpers={arrayHelpers} - index={ecologicalUnitIndex} + {selectedUnits.map((_, ecologicalUnitIndex) => ( + unit.critterbase_collection_unit_id) + .map((unit) => unit.critterbase_collection_unit_id!)} + onDelete={() => arrayHelpers.remove(ecologicalUnitIndex)} /> ))} @@ -82,7 +80,11 @@ export const FocalSpeciesEcologicalUnitsForm = (props: ISelectedSpeciesProps) => onClick={() => arrayHelpers.push(initialEcologicalUnitValues)} startIcon={} aria-label="Add Ecological Unit" - disabled={!(ecologicalUnitsForSpecies.length && selectedUnits.length < ecologicalUnitsForSpecies.length)} + disabled={ + !( + ecologicalUnitsForSpecies.length && selectedUnits.every((unit) => unit.critterbase_collection_unit_id) + ) + } sx={{ textTransform: 'uppercase' }}> Add Ecological Unit diff --git a/app/src/features/surveys/list/SurveysListPage.test.tsx b/app/src/features/surveys/list/SurveysListPage.test.tsx index 9164535404..377fea00c5 100644 --- a/app/src/features/surveys/list/SurveysListPage.test.tsx +++ b/app/src/features/surveys/list/SurveysListPage.test.tsx @@ -18,6 +18,12 @@ const history = createMemoryHistory(); jest.mock('../../../hooks/useBioHubApi'); const mockBiohubApi = useBiohubApi as jest.Mock; +jest.mock('../../../components/markdown/CustomMarkdown', () => { + // Overriding this component because it is ESM only and Jest does not support ESM. + // See https://github.com/orgs/remarkjs/discussions/1247 for more information. + return {}; +}); + const mockUseApi = { survey: { getSurveysBasicFieldsByProjectId: jest.fn() diff --git a/app/src/features/surveys/list/SurveysListPage.tsx b/app/src/features/surveys/list/SurveysListPage.tsx index 55474b8ba4..14c5da20ab 100644 --- a/app/src/features/surveys/list/SurveysListPage.tsx +++ b/app/src/features/surveys/list/SurveysListPage.tsx @@ -2,12 +2,14 @@ import { mdiArrowTopRight, mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; -import { grey } from '@mui/material/colors'; +import grey from '@mui/material/colors/grey'; import Divider from '@mui/material/Divider'; import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { LoadingGuard } from 'components/loading/LoadingGuard'; import { SkeletonTable } from 'components/loading/SkeletonLoaders'; @@ -16,6 +18,7 @@ import { ProjectRoleGuard } from 'components/security/Guards'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; import { ProjectContext } from 'contexts/projectContext'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { SurveyBasicFieldsObject } from 'interfaces/useSurveyApi.interface'; import { useContext, useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; @@ -63,8 +66,8 @@ const SurveysListPage = () => { { field: 'survey_id', headerName: 'ID', - width: 70, - minWidth: 70, + width: 85, + minWidth: 85, renderHeader: () => ( ID @@ -138,20 +141,23 @@ const SurveysListPage = () => { ({Number(projectContext.surveysListDataLoader.data?.pagination?.total ?? 0).toLocaleString()}) - - - + + + + + + diff --git a/app/src/features/surveys/observations/SurveyObservationHeader.tsx b/app/src/features/surveys/observations/SurveyObservationHeader.tsx index 74bd6deb39..61e5974c46 100644 --- a/app/src/features/surveys/observations/SurveyObservationHeader.tsx +++ b/app/src/features/surveys/observations/SurveyObservationHeader.tsx @@ -1,6 +1,7 @@ +import { mdiPaw, mdiWifiMarker } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; @@ -13,6 +14,20 @@ export interface SurveyObservationHeaderProps { const SurveyObservationHeader: React.FC = (props) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Animals', + to: `/admin/projects/${project_id}/surveys/${survey_id}/animals/details`, + icon: mdiPaw + }, + { + label: 'Telemetry', + to: `/admin/projects/${project_id}/surveys/${survey_id}/telemetry`, + icon: mdiWifiMarker + } + ]; + return ( = (props) to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Observations - + Observations } /> diff --git a/app/src/features/surveys/observations/SurveyObservationPage.tsx b/app/src/features/surveys/observations/SurveyObservationPage.tsx index 775702eccd..ffce32625f 100644 --- a/app/src/features/surveys/observations/SurveyObservationPage.tsx +++ b/app/src/features/surveys/observations/SurveyObservationPage.tsx @@ -36,7 +36,6 @@ export const SurveyObservationPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> - { const codesContext = useCodesContext(); - const surveyContext = useSurveyContext(); + const observationsPageContext = useObservationsPageContext(); const observationsTableContext = useObservationsTableContext(); + const observationsContext = useObservationsContext(); useEffect(() => { codesContext.codesDataLoader.load(); - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [ - codesContext.codesDataLoader, - surveyContext.projectId, - surveyContext.sampleSiteDataLoader, - surveyContext.surveyId - ]); - - // Collect sample sites - const surveySampleSites: IGetSampleLocationDetails[] = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); - - const sampleSiteOptions: ISampleSiteOption[] = useMemo( - () => - surveySampleSites.map((site) => ({ - survey_sample_site_id: site.survey_sample_site_id, - sample_site_name: site.name - })) ?? [], - [surveySampleSites] - ); - - // Collect sample methods - const surveySampleMethods: IGetSampleMethodDetails[] = surveySampleSites - .filter((sampleSite) => Boolean(sampleSite.sample_methods)) - .map((sampleSite) => sampleSite.sample_methods as IGetSampleMethodDetails[]) - .flat(2); - const sampleMethodOptions: ISampleMethodOption[] = surveySampleMethods.map((method) => ({ - survey_sample_method_id: method.survey_sample_method_id, - survey_sample_site_id: method.survey_sample_site_id, - sample_method_name: method.technique.name, - response_metric: - getCodesName(codesContext.codesDataLoader.data, 'method_response_metrics', method.method_response_metric_id) ?? '' - })); - - // Collect sample periods - const samplePeriodOptions: ISamplePeriodOption[] = surveySampleMethods - .filter((sampleMethod) => Boolean(sampleMethod.sample_periods)) - .map((sampleMethod) => sampleMethod.sample_periods as IGetSamplePeriodRecord[]) - .flat(2) - .map((samplePeriod: IGetSamplePeriodRecord) => ({ - survey_sample_period_id: samplePeriod.survey_sample_period_id, - survey_sample_method_id: samplePeriod.survey_sample_method_id, - sample_period_name: `${samplePeriod.start_date} ${samplePeriod.start_time ?? ''} - ${samplePeriod.end_date} ${ - samplePeriod.end_time ?? '' - }` - })); + }, [codesContext.codesDataLoader]); const observationSubcountSignOptions = useMemo( () => @@ -119,56 +74,102 @@ const ObservationsTableContainer = () => { [codesContext.codesDataLoader.data?.observation_subcount_signs] ); + const sampleLocationsCache = useSampleLocationsCache(); + + useEffect(() => { + if (!observationsContext.observationsDataLoader.data?.supplementaryObservationData.sample_sites?.length) { + return; + } + + sampleLocationsCache.updateCachedSampleLocationsRef( + observationsContext.observationsDataLoader.data.supplementaryObservationData.sample_sites + ); + }, [ + observationsContext.observationsDataLoader.data?.supplementaryObservationData.sample_sites, + sampleLocationsCache + ]); + // The column definitions of the columns to render in the observations table const columns: GridColDef[] = useMemo( - () => [ - // Add standard observation columns to the table - TaxonomyColDef({ hasError: observationsTableContext.hasError }), - SampleSiteColDef({ sampleSiteOptions, hasError: observationsTableContext.hasError }), - SampleMethodColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - SamplePeriodColDef({ samplePeriodOptions, hasError: observationsTableContext.hasError }), - ObservationSubcountSignColDef({ observationSubcountSignOptions, hasError: observationsTableContext.hasError }), - ObservationCountColDef({ sampleMethodOptions, hasError: observationsTableContext.hasError }), - GenericDateColDef({ - field: 'observation_date', - headerName: 'Date', - hasError: observationsTableContext.hasError, - description: 'The date when the observation was made' - }), - GenericTimeColDef({ - field: 'observation_time', - headerName: 'Time', - hasError: observationsTableContext.hasError, - description: 'The time of day when the observation was made' - }), - GenericLatitudeColDef({ - field: 'latitude', - headerName: 'Latitude', - hasError: observationsTableContext.hasError, - description: 'The latitude where the observation was made' - }), - GenericLongitudeColDef({ - field: 'longitude', - headerName: 'Longitude', - hasError: observationsTableContext.hasError, - description: 'The longitude where the observation was made' - }), - // Add measurement columns to the table - ...getMeasurementColumnDefinitions( - observationsTableContext.measurementColumns, - observationsTableContext.hasError - ), - // Add environment columns to the table - ...getEnvironmentColumnDefinitions(observationsTableContext.environmentColumns, observationsTableContext.hasError) - ], + () => { + return [ + // Add standard observation columns to the table + TaxonomyColDef({ hasError: observationsTableContext.hasError }), + SampleSiteColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + onSelectOption: (selectedSampleSite) => { + if (!selectedSampleSite) { + return; + } + + sampleLocationsCache.updateCachedSampleLocationsRef([selectedSampleSite]); + }, + hasError: observationsTableContext.hasError + }), + SampleMethodColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + SamplePeriodColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + ObservationSubcountSignColDef({ observationSubcountSignOptions, hasError: observationsTableContext.hasError }), + ObservationCountColDef({ + cachedSampleLocationsRef: sampleLocationsCache.cachedSampleLocationsRef, + hasError: observationsTableContext.hasError + }), + GenericDateColDef({ + field: 'observation_date', + headerName: 'Date', + hasError: observationsTableContext.hasError, + description: 'The date when the observation was made' + }), + GenericTimeColDef({ + field: 'observation_time', + headerName: 'Time', + hasError: observationsTableContext.hasError, + description: 'The time of day when the observation was made' + }), + GenericLatitudeColDef({ + field: 'latitude', + headerName: 'Latitude', + hasError: observationsTableContext.hasError, + description: 'The latitude where the observation was made' + }), + GenericLongitudeColDef({ + field: 'longitude', + headerName: 'Longitude', + hasError: observationsTableContext.hasError, + description: 'The longitude where the observation was made' + }), + // Add measurement columns to the table + ...getMeasurementColumnDefinitions( + observationsTableContext.measurementColumns, + observationsTableContext.hasError + ), + // Add environment columns to the table + ...getEnvironmentColumnDefinitions( + observationsTableContext.environmentColumns, + observationsTableContext.hasError + ), + GenericCommentColDef({ + field: 'comment', + headerName: '', + hasError: observationsTableContext.hasError, + handleOpen: (params: GridRenderEditCellParams) => observationsTableContext.setCommentDialogParams(params), + handleClose: () => observationsTableContext.setCommentDialogParams(null) + }) + ]; + }, + // observationsTableContext is listed as a missing dependency + // eslint-disable-next-line react-hooks/exhaustive-deps [ observationSubcountSignOptions, observationsTableContext.environmentColumns, observationsTableContext.hasError, observationsTableContext.measurementColumns, - sampleMethodOptions, - samplePeriodOptions, - sampleSiteOptions + observationsTableContext.setCommentDialogParams ] ); @@ -193,6 +194,7 @@ const ObservationsTableContainer = () => { + observationsPageContext.setIsDisabled(true)} @@ -239,6 +241,25 @@ const ObservationsTableContainer = () => { muiDataGridApiRef={observationsTableContext._muiDataGridApiRef.current} /> + observationsTableContext.setCommentDialogParams(null)} + handleSave={(value) => { + if (!observationsTableContext.commentDialogParams) { + return; + } + + observationsTableContext.commentDialogParams.api.setEditCellValue({ + value, + id: observationsTableContext.commentDialogParams.id, + field: observationsTableContext.commentDialogParams.field + }); + }} + /> + { observationsTableContext.isDisabled || codesContext.codesDataLoader.isLoading } - columns={columns} + columns={[...columns]} /> diff --git a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx index a75fcc438b..4a25f46e85 100644 --- a/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx +++ b/app/src/features/surveys/observations/observations-table/configure-columns/components/ConfigureColumnsPage.tsx @@ -1,10 +1,7 @@ import { mdiCog, mdiLeaf, mdiRuler } from '@mdi/js'; -import Icon from '@mdi/react'; -import Button from '@mui/material/Button'; import Grid from '@mui/material/Grid'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import { GridColDef } from '@mui/x-data-grid'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; import { IObservationTableRow } from 'contexts/observationsTableContext'; import { ConfigureEnvironmentColumns } from 'features/surveys/observations/observations-table/configure-columns/components/environment/ConfigureEnvironmentColumns'; import { ConfigureGeneralColumns } from 'features/surveys/observations/observations-table/configure-columns/components/general/ConfigureGeneralColumns'; @@ -125,65 +122,21 @@ export const ConfigureColumnsPage = (props: IConfigureColumnsPageProps) => { const [activeView, setActiveView] = useState(ConfigureColumnsViewEnum.GENERAL); + const views = [ + { value: ConfigureColumnsViewEnum.GENERAL, label: 'General', icon: mdiCog }, + { value: ConfigureColumnsViewEnum.MEASUREMENTS, label: 'Species Attributes', icon: mdiRuler }, + { value: ConfigureColumnsViewEnum.ENVIRONMENT, label: 'Environment', icon: mdiLeaf } + ]; + return ( - { - if (!view) { - // An active view must be selected at all times - return; - } - - setActiveView(view); - }} - exclusive + setActiveView(view)} orientation="vertical" - sx={{ - width: '100%', - gap: 1, - '& Button': { - textAlign: 'left', - display: 'flex', - justifyContent: 'flex-start', - py: 1, - px: 2, - border: 'none', - borderRadius: '4px !important', - fontSize: '0.875rem', - fontWeight: 700, - letterSpacing: '0.02rem' - } - }}> - } - disabled={disabled} - value={ConfigureColumnsViewEnum.GENERAL}> - General - - } - disabled={disabled} - value={ConfigureColumnsViewEnum.MEASUREMENTS}> - Species Attributes - - } - disabled={disabled} - value={ConfigureColumnsViewEnum.ENVIRONMENT}> - Environment - - + /> {activeView === ConfigureColumnsViewEnum.GENERAL && ( diff --git a/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx b/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx index 3234319e7e..93556933ed 100644 --- a/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx +++ b/app/src/features/surveys/observations/observations-table/export-button/ExportHeadersButton.tsx @@ -22,7 +22,12 @@ const ExportHeadersButton = () => { const headerNames = tableColumns .filter((column) => !excludedFields.includes(column.field)) - .map((column) => column.headerName); + .map((column) => column.headerName) + // Remove action columns that do not have a label, replaced below + .filter(Boolean); + + // Inject action columns that do not have a label (eg. comment) + headerNames.push('comment'); const csvObject: Record[] = [Object.fromEntries(headerNames.map((headerName) => [headerName, '']))]; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx index 7001069440..c809c0f4f7 100644 --- a/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/GridColumnDefinitions.tsx @@ -2,17 +2,29 @@ import Typography from '@mui/material/Typography'; import { GridCellParams, GridColDef } from '@mui/x-data-grid'; import AutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AutocompleteDataGridEditCell'; import AutocompleteDataGridViewCell from 'components/data-grid/autocomplete/AutocompleteDataGridViewCell'; -import ConditionalAutocompleteDataGridEditCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridEditCell'; -import ConditionalAutocompleteDataGridViewCell from 'components/data-grid/conditional-autocomplete/ConditionalAutocompleteDataGridViewCell'; import TaxonomyDataGridEditCell from 'components/data-grid/taxonomy/TaxonomyDataGridEditCell'; import TaxonomyDataGridViewCell from 'components/data-grid/taxonomy/TaxonomyDataGridViewCell'; import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; import { IObservationTableRow } from 'contexts/observationsTableContext'; +import { ObservationCountDataGridEditCell } from 'features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell'; +import SampleMethodDataGridEditCell from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell'; +import { SampleMethodDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell'; +import SamplePeriodDataGridEditCell from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell'; +import { SamplePeriodDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell'; +import { SampleSiteDataGridEditCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell'; +import { SampleSiteDataGridViewCell } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell'; +import { + getMethodsForRow, + getPeriodsForRow +} from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; import { CBMeasurementType, CBQualitativeOption } from 'interfaces/useCritterApi.interface'; import { EnvironmentQualitativeTypeDefinition, EnvironmentQuantitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { MutableRefObject } from 'react'; export type ISampleSiteOption = { survey_sample_site_id: number; @@ -66,10 +78,11 @@ export const TaxonomyColDef = (props: { }; export const SampleSiteColDef = (props: { - sampleSiteOptions: ISampleSiteOption[]; + cachedSampleLocationsRef: MutableRefObject; + onSelectOption: (selectedSampleSite: IGetSampleLocationNonSpatialDetails | null) => void; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { sampleSiteOptions, hasError } = props; + const { cachedSampleLocationsRef, onSelectOption, hasError } = props; return { field: 'survey_sample_site_id', @@ -84,24 +97,19 @@ export const SampleSiteColDef = (props: { align: 'left', renderCell: (params) => { return ( - + ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { return ( - + ({ - label: item.sample_site_name, - value: item.survey_sample_site_id - }))} + cachedSampleLocationsRef={cachedSampleLocationsRef} + onSelectOption={(selectedSampleSite) => onSelectOption(selectedSampleSite)} error={hasError(params)} /> ); @@ -110,10 +118,10 @@ export const SampleSiteColDef = (props: { }; export const SampleMethodColDef = (props: { - sampleMethodOptions: ISampleMethodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { sampleMethodOptions, hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'survey_sample_method_id', @@ -128,28 +136,21 @@ export const SampleMethodColDef = (props: { align: 'left', renderCell: (params) => { return ( - + { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sampleMethodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { + const methodOptions = getMethodsForRow(params, cachedSampleLocationsRef); + return ( - + { - return allOptions - .filter((item) => item.survey_sample_site_id === row.survey_sample_site_id) - .map((item) => ({ label: item.sample_method_name, value: item.survey_sample_method_id })); - }} - allOptions={sampleMethodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} + methodOptions={methodOptions} error={hasError(params)} /> ); @@ -158,10 +159,10 @@ export const SampleMethodColDef = (props: { }; export const SamplePeriodColDef = (props: { - samplePeriodOptions: ISamplePeriodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { samplePeriodOptions, hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'survey_sample_period_id', @@ -169,41 +170,28 @@ export const SamplePeriodColDef = (props: { description: 'The sampling period in which the observation was made', editable: true, hideable: true, - flex: 0, + flex: 1, minWidth: 180, disableColumnMenu: true, headerAlign: 'left', align: 'left', renderCell: (params) => { return ( - + { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ - label: item.sample_period_name, - value: item.survey_sample_period_id - })); - }} - allOptions={samplePeriodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} error={hasError(params)} /> ); }, renderEditCell: (params) => { + const periodOptions = getPeriodsForRow(params, cachedSampleLocationsRef); + return ( - + { - return allOptions - .filter((item) => item.survey_sample_method_id === row.survey_sample_method_id) - .map((item) => ({ - label: item.sample_period_name, - value: item.survey_sample_period_id - })); - }} - allOptions={samplePeriodOptions} + cachedSampleLocationsRef={cachedSampleLocationsRef} + periodOptions={periodOptions} error={hasError(params)} /> ); @@ -212,10 +200,10 @@ export const SamplePeriodColDef = (props: { }; export const ObservationCountColDef = (props: { - sampleMethodOptions: ISampleMethodOption[]; + cachedSampleLocationsRef: MutableRefObject; hasError: (params: GridCellParams) => boolean; }): GridColDef => { - const { hasError } = props; + const { cachedSampleLocationsRef, hasError } = props; return { field: 'count', @@ -234,39 +222,11 @@ export const ObservationCountColDef = (props: { ), renderEditCell: (params) => { - const error: boolean = hasError(params); - - const maxCount = - props.sampleMethodOptions.find( - (option) => option.survey_sample_method_id === params.row.survey_sample_method_id - )?.response_metric === 'Presence-absence' - ? 1 - : undefined; - return ( - { - if (!/^\d{0,7}$/.test(event.target.value)) { - // If the value is not a number, return - return; - } - - params.api.setEditCellValue({ - id: params.id, - field: params.field, - value: event.target.value - }); - }, - error - }} + cachedSampleLocationsRef={cachedSampleLocationsRef} + error={hasError(params)} /> ); } @@ -278,6 +238,7 @@ export const ObservationSubcountSignColDef = (props: { hasError: (params: GridCellParams) => boolean; }): GridColDef => { const { observationSubcountSignOptions, hasError } = props; + const signOptions = observationSubcountSignOptions.map((item) => ({ label: item.name, value: item.observation_subcount_sign_id diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/comment/ObservationSubcountCommentDialog.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/comment/ObservationSubcountCommentDialog.tsx new file mode 100644 index 0000000000..b259bf1aba --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/comment/ObservationSubcountCommentDialog.tsx @@ -0,0 +1,90 @@ +import LoadingButton from '@mui/lab/LoadingButton'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import TextField from '@mui/material/TextField'; +import { useState } from 'react'; + +interface IObservationSubcountCommentDialogProps { + /** + * The initial value of the comment. + * + * @type {string} + */ + initialValue?: string; + /** + * The open state of the dialog. + * + * @type {boolean} + */ + open: boolean; + /** + * Callback to close the dialog. + * + * @type {(value?: string) => void} + */ + handleClose: () => void; + /** + * Callback to save the comment. + * + * @type {(value?: string) => void} + */ + handleSave: (value?: string) => void; +} + +/** + * Dialog for adding comments to an observation. + * + * @param {IObservationSubcountCommentDialogProps} props + * @returns {*} {JSX.Element} + */ +export const ObservationSubcountCommentDialog = (props: IObservationSubcountCommentDialogProps) => { + // Hold the intial value in state so that we can reset the comment if the user cancels + const [comment, setComment] = useState(props.initialValue); + + return ( + + Add Comment + + setComment(event.target.value)} + /> + + + { + // Close the dialog and save the comment + props.handleClose(); + props.handleSave(comment); + }} + color="primary" + variant="contained"> + Save & close + + + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx new file mode 100644 index 0000000000..bf762b996e --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/count/ObservationCountDataGridEditCell.tsx @@ -0,0 +1,71 @@ +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import TextFieldDataGrid from 'components/data-grid/TextFieldDataGrid'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { useCodesContext } from 'hooks/useContext'; +import { MutableRefObject } from 'react'; +import { getCodesName } from 'utils/Utils'; + +export interface IPartialObservationCountDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {IPartialObservationCountDataGridEditCellProps} props + * @return {*} + */ +export const ObservationCountDataGridEditCell = ( + props: IPartialObservationCountDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const codesContext = useCodesContext(); + + const getResponseMetric = () => { + const currentMethod = getCurrentMethod(dataGridProps, cachedSampleLocationsRef); + + if (!currentMethod) { + return null; + } + + return getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + currentMethod.method_response_metric_id + ); + }; + + const maxCount = getResponseMetric() === 'Presence-absence' ? 1 : undefined; + + return ( + { + if (!/^\d{0,7}$/.test(event.target.value)) { + // If the value is not a number, return + return; + } + + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: event.target.value + }); + }, + error + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts new file mode 100644 index 0000000000..e3c5e864a1 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface.ts @@ -0,0 +1,6 @@ +import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; + +export interface IAutocompleteDataGridSampleMethodOption extends IGetSampleMethodDetails { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx new file mode 100644 index 0000000000..d08df773d6 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridEditCell.tsx @@ -0,0 +1,117 @@ +import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSampleMethodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject, useRef } from 'react'; + +export interface ISampleMethodDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + methodOptions: IAutocompleteDataGridSampleMethodOption[]; + onSelectOption?: (selectedSampleSite: IAutocompleteDataGridSampleMethodOption | null) => void; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {ISampleMethodDataGridEditCellProps} props + * @return {*} + */ +const SampleMethodDataGridEditCell = ( + props: ISampleMethodDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, methodOptions, onSelectOption, error } = props; + + const ref = useRef(); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + + function getCurrentValue() { + const currentMethod = getCurrentMethod(dataGridProps, cachedSampleLocationsRef); + + return currentMethod; + } + + return ( + option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={createFilterOptions({ limit: 50 })} + onChange={(_, selectedOption) => { + // Set the sample method value with selected options value + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: selectedOption?.value + }); + + // If the sample method is changed, clear the sample period as it is dependent on the method + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_period_id', + value: null + }); + + onSelectOption?.(selectedOption); + }} + renderInput={(params) => ( + + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={dataGridProps.id} + /> + ); +}; + +export default SampleMethodDataGridEditCell; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx new file mode 100644 index 0000000000..ed491a67f2 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentMethod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSampleMethodDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSampleMethodDataGridViewCellProps} props + * @return {*} + */ +export const SampleMethodDataGridViewCell = ( + props: IPartialSampleMethodDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentMethod(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts new file mode 100644 index 0000000000..0d9eb6d4cc --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface.ts @@ -0,0 +1,6 @@ +import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; + +export interface IAutocompleteDataGridSamplePeriodOption extends IGetSamplePeriodRecord { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx new file mode 100644 index 0000000000..7c57b64439 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridEditCell.tsx @@ -0,0 +1,110 @@ +import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import TextField from '@mui/material/TextField'; +import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSamplePeriodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface'; +import { getCurrentPeriod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject, useRef } from 'react'; + +export interface ISamplePeriodDataGridEditCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + periodOptions: IAutocompleteDataGridSamplePeriodOption[]; + onSelectOption?: (selectedSampleSite: IAutocompleteDataGridSamplePeriodOption | null) => void; + error?: boolean; +} + +/** + * + * + * @template DataGridType + * @param {ISamplePeriodDataGridEditCellProps} props + * @return {*} + */ +const SamplePeriodDataGridEditCell = ( + props: ISamplePeriodDataGridEditCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, periodOptions, onSelectOption, error } = props; + + const ref = useRef(); + + useEnhancedEffect(() => { + if (dataGridProps.hasFocus) { + ref.current?.focus(); + } + }, [dataGridProps.hasFocus]); + + function getCurrentValue() { + const currentPeriod = getCurrentPeriod(dataGridProps, cachedSampleLocationsRef); + + return currentPeriod; + } + + return ( + option.label} + isOptionEqualToValue={(option, value) => { + if (!option?.value || !value?.value) { + return false; + } + return option.value === value.value; + }} + filterOptions={createFilterOptions({ limit: 50 })} + onChange={(_, selectedOption) => { + // Set the sample period value with selected options value + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: dataGridProps.field, + value: selectedOption?.value + }); + + onSelectOption?.(selectedOption); + }} + renderInput={(params) => ( + + )} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + data-testid={dataGridProps.id} + /> + ); +}; + +export default SamplePeriodDataGridEditCell; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx new file mode 100644 index 0000000000..45f25c670c --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentPeriod } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSamplePeriodDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSamplePeriodDataGridViewCellProps} props + * @return {*} + */ +export const SamplePeriodDataGridViewCell = ( + props: IPartialSamplePeriodDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentPeriod(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts new file mode 100644 index 0000000000..6429f69518 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface.ts @@ -0,0 +1,13 @@ +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; + +/** + * Defines a single option for a data grid taxonomy autocomplete control. + * + * @export + * @interface IAutocompleteDataGridSampleSiteOption + * @extends {IPartialSampleSite} + */ +export interface IAutocompleteDataGridSampleSiteOption extends IGetSampleLocationNonSpatialDetails { + value: number; + label: string; +} diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx new file mode 100644 index 0000000000..8efb849320 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridEditCell.tsx @@ -0,0 +1,185 @@ +import Box from '@mui/material/Box'; +import { grey } from '@mui/material/colors'; +import { GridRenderEditCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import AsyncAutocompleteDataGridEditCell from 'components/data-grid/autocomplete/AsyncAutocompleteDataGridEditCell'; +import { IAutocompleteDataGridSampleSiteOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface'; +import { getCurrentSite } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useIsMounted from 'hooks/useIsMounted'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import debounce from 'lodash-es/debounce'; +import { MutableRefObject, useMemo } from 'react'; + +export interface ISampleSiteDataGridCellProps { + dataGridProps: GridRenderEditCellParams; + cachedSampleLocationsRef: MutableRefObject; + onSelectOption?: (selectedSampleSite: IGetSampleLocationNonSpatialDetails | null) => void; + error?: boolean; +} + +/** + * Data grid taxonomy component for edit. + * + * @template DataGridType + * @template ValueType + * @param {ISampleSiteDataGridCellProps} props + * @return {*} + */ +export const SampleSiteDataGridEditCell = ( + props: ISampleSiteDataGridCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, onSelectOption, error } = props; + + const biohubApi = useBiohubApi(); + const surveyContext = useSurveyContext(); + + const isMounted = useIsMounted(); + + /** + * Get the current option for the autocomplete, if the field has a value. + * + * @return {*} {(Promise)} + */ + const getCurrentOption = async (): Promise => { + const currentSite = getCurrentSite(dataGridProps, cachedSampleLocationsRef); + + if (!currentSite) { + return null; + } + + return { + ...currentSite, + label: currentSite.name, + value: currentSite.survey_sample_site_id + }; + }; + + /** + * Merge the cached sample locations with the new options returned by the async search, removing duplicates. + * + * @param {IGetSampleLocationNonSpatialDetails[]} cachedOptions + * @param {IGetSampleLocationNonSpatialDetails[]} options + * @return {*} + */ + const mergeOptions = ( + cachedOptions: IGetSampleLocationNonSpatialDetails[], + options: IGetSampleLocationNonSpatialDetails[] + ) => { + const mergedOptionsMap = new Map(); + + // Merge the cached options with the new options, ensuring no duplicates + [...options, ...cachedOptions].forEach((item) => { + mergedOptionsMap.set(item.survey_sample_site_id, { + ...item, + label: item.name, + value: item.survey_sample_site_id + }); + }); + + return Array.from(mergedOptionsMap.values()).sort((a, b) => a.name.localeCompare(b.name)); + }; + + /** + * Debounced function to get the options for the autocomplete, based on the search term. + * Includes the cached sample locations in the resulting options array. + */ + const getOptions = useMemo( + () => + debounce( + async ( + searchTerm: string, + onSearchResults: (searchedValues: IAutocompleteDataGridSampleSiteOption[]) => void + ) => { + const keyword = searchTerm?.trim(); + + biohubApi.samplingSite + .getSampleSites(surveyContext.projectId, surveyContext.surveyId, { keyword }) + .then((response) => { + const options = response.sampleSites.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })); + + if (!isMounted()) { + return; + } + + const mergedOptions = mergeOptions(cachedSampleLocationsRef.current?.locations ?? [], options); + + onSearchResults(mergedOptions); + }); + + onSearchResults( + cachedSampleLocationsRef.current?.locations.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })) ?? [] + ); + }, + 500 + ), + [biohubApi.samplingSite, cachedSampleLocationsRef, isMounted, surveyContext.projectId, surveyContext.surveyId] + ); + + /** + * Get the initial options for the autocomplete. + * + * @return {*} + */ + const getInitialOptions = () => { + return ( + cachedSampleLocationsRef.current?.locations.map((item) => ({ + ...item, + label: item.name, + value: item.survey_sample_site_id + })) ?? [] + ); + }; + + return ( + { + // If the sample site is changed, clear the sample method and period as they are dependent on the site + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_method_id', + value: null + }); + dataGridProps.api.setEditCellValue({ + id: dataGridProps.id, + field: 'survey_sample_period_id', + value: null + }); + + onSelectOption?.(selectedOption); + }} + placeholder="Search for a site" + error={error} + renderOption={(renderProps, renderOption) => { + return ( + + + {renderOption.label} + + + ); + }} + /> + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx new file mode 100644 index 0000000000..34f1674516 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGridViewCell.tsx @@ -0,0 +1,48 @@ +import Typography from '@mui/material/Typography'; +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { getCurrentSite } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { MutableRefObject } from 'react'; + +export interface IPartialSampleSiteDataGridViewCellProps { + dataGridProps: GridRenderCellParams; + cachedSampleLocationsRef: MutableRefObject; + error?: boolean; +} + +/** + * Data grid taxonomy component for view. + * + * @template DataGridType + * @param {IPartialSampleSiteDataGridViewCellProps} props + * @return {*} + */ +export const SampleSiteDataGridViewCell = ( + props: IPartialSampleSiteDataGridViewCellProps +) => { + const { dataGridProps, cachedSampleLocationsRef, error } = props; + + const label = getCurrentSite(dataGridProps, cachedSampleLocationsRef)?.label ?? ''; + + return ( + + + {label} + + + ); +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx new file mode 100644 index 0000000000..70fc75d86e --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/useSampleLocationsCache.tsx @@ -0,0 +1,49 @@ +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { useRef } from 'react'; + +export type SampleLocationCache = { + locations: IGetSampleLocationNonSpatialDetails[]; +}; + +export const useSampleLocationsCache = () => { + const cachedSampleLocationsRef = useRef(); + + const updateCachedSampleLocationsRef = (selectedSampleSites: IGetSampleLocationNonSpatialDetails[]) => { + if (!selectedSampleSites?.length) { + // If the selected sample site is null, nothing to add to the cache + return; + } + + if (!cachedSampleLocationsRef.current) { + // Initialize the cache + cachedSampleLocationsRef.current = { + locations: selectedSampleSites + }; + } + + const newSites = []; + + for (const site of selectedSampleSites) { + if ( + cachedSampleLocationsRef.current.locations.findIndex( + (item) => item.survey_sample_site_id === site.survey_sample_site_id + ) !== -1 + ) { + // The site is already in the cache + continue; + } + + newSites.push(site); + } + + // Update the cache + cachedSampleLocationsRef.current = { + locations: [...cachedSampleLocationsRef.current.locations, ...newSites] + }; + }; + + return { + cachedSampleLocationsRef, + updateCachedSampleLocationsRef + }; +}; diff --git a/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts new file mode 100644 index 0000000000..aa6a6fe630 --- /dev/null +++ b/app/src/features/surveys/observations/observations-table/grid-column-definitions/sampling-information/utils.ts @@ -0,0 +1,152 @@ +import { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; +import { IAutocompleteDataGridSampleMethodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/methods/SampleMethodDataGrid.interface'; +import { IAutocompleteDataGridSamplePeriodOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/periods/SamplePeriodDataGrid.interface'; +import { IAutocompleteDataGridSampleSiteOption } from 'features/surveys/observations/observations-table/grid-column-definitions/sampling-information/sites/SampleSiteDataGrid.interface'; +import { SampleLocationCache } from 'features/surveys/observations/observations-table/ObservationsTableContainer'; +import { IGetSampleLocationNonSpatialDetails, IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; +import { MutableRefObject } from 'react'; + +/** + * Given a site id and sample location cache, find the site object. + * + * @param {(number | undefined)} siteId + * @param {(SampleLocationCache | undefined)} cache + */ +const findSite = (siteId: number | undefined, cache: SampleLocationCache | undefined) => + cache?.locations.find((site) => site.survey_sample_site_id === siteId); + +/** + * Given a sample site object and method id, find the method object. + * + * @param {(IGetSampleLocationNonSpatialDetails | undefined)} site + * @param {(number | undefined)} methodId + */ +const findMethod = (site: IGetSampleLocationNonSpatialDetails | undefined, methodId: number | undefined) => + site?.sample_methods.find((method) => method.survey_sample_method_id === methodId); + +/** + * Transform a sampling option to be compatible with the autocomplete control. + * + * @template T + * @param {T} item + * @param {string} label + * @param {number} value + * @return {*} {(T & { label: string; value: number })} + */ +const formatOption = (item: T, label: string, value: number): T & { label: string; value: number } => ({ + ...item, + label, + value +}); + +/** + * Get the label for a period. + * + * @param {(IGetSamplePeriodRecord | null)} period + * @return {*} + */ +const getPeriodLabel = (period: IGetSamplePeriodRecord | null) => { + if (!period) { + return ''; + } + return `${period.start_date} ${period.start_time ?? ''} - ${period.end_date} ${period.end_time ?? ''}`; +}; + +/** + * Get the currently selected site for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSampleSiteOption | null)} + */ +export const getCurrentSite = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleSiteOption | null => { + const currentSite = findSite(dataGridProps.value as number, cachedSampleLocationsRef.current); + return currentSite ? formatOption(currentSite, currentSite.name, currentSite.survey_sample_site_id) : null; +}; + +/** + * Get the currently selected method for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSampleMethodOption | null)} + */ +export const getCurrentMethod = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleMethodOption | null => { + for (const site of cachedSampleLocationsRef.current?.locations ?? []) { + const currentMethod = findMethod(site, dataGridProps.value as number); + if (currentMethod) { + return formatOption(currentMethod, currentMethod.technique.name, currentMethod.survey_sample_method_id); + } + } + return null; +}; + +/** + * Get the currently selected period for the row. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {(IAutocompleteDataGridSamplePeriodOption | null)} + */ +export const getCurrentPeriod = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSamplePeriodOption | null => { + for (const site of cachedSampleLocationsRef.current?.locations ?? []) { + for (const method of site.sample_methods ?? []) { + const currentPeriod = method.sample_periods.find( + (period) => period.survey_sample_period_id === dataGridProps.value + ); + if (currentPeriod) { + return formatOption(currentPeriod, getPeriodLabel(currentPeriod), currentPeriod.survey_sample_period_id); + } + } + } + return null; +}; + +/** + * Get all valid methods for the currently selected site. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {IAutocompleteDataGridSampleMethodOption[]} + */ +export const getMethodsForRow = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSampleMethodOption[] => { + const site = findSite(dataGridProps.row.survey_sample_site_id, cachedSampleLocationsRef.current); + return (site?.sample_methods ?? []).map((method) => + formatOption(method, method.technique.name, method.survey_sample_method_id) + ); +}; + +/** + * Get all valid periods for the currently selected site and method. + * + * @template DataGridType + * @param {GridRenderCellParams} dataGridProps + * @param {(MutableRefObject)} cachedSampleLocationsRef + * @return {*} {IAutocompleteDataGridSamplePeriodOption[]} + */ +export const getPeriodsForRow = ( + dataGridProps: GridRenderCellParams, + cachedSampleLocationsRef: MutableRefObject +): IAutocompleteDataGridSamplePeriodOption[] => { + const site = findSite(dataGridProps.row.survey_sample_site_id, cachedSampleLocationsRef.current); + const method = findMethod(site, dataGridProps.row.survey_sample_method_id); + return (method?.sample_periods ?? []).map((period) => + formatOption(period, getPeriodLabel(period), period.survey_sample_period_id) + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx index edbc98e77e..54b825fd66 100644 --- a/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx +++ b/app/src/features/surveys/observations/sampling-sites/SamplingSiteListContainer.tsx @@ -14,14 +14,21 @@ import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; +import TablePagination from '@mui/material/TablePagination'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import { SkeletonList } from 'components/loading/SkeletonLoaders'; -import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListSite'; +import { SamplingSiteListSite } from 'features/surveys/observations/sampling-sites/site/SamplingSiteListSite'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useObservationsPageContext, useSurveyContext } from 'hooks/useContext'; -import { useEffect, useState } from 'react'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; + +const pageSizeOptions = [10, 25, 50, 1000]; /** * Renders a list of sampling sites. @@ -34,16 +41,43 @@ export const SamplingSiteListContainer = () => { const observationsPageContext = useObservationsPageContext(); const biohubApi = useBiohubApi(); - useEffect(() => { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [surveyContext.projectId, surveyContext.sampleSiteDataLoader, surveyContext.surveyId]); - const [sampleSiteAnchorEl, setSampleSiteAnchorEl] = useState(null); const [headerAnchorEl, setHeaderAnchorEl] = useState(null); const [selectedSampleSiteId, setSelectedSampleSiteId] = useState(); const [checkboxSelectedIds, setCheckboxSelectedIds] = useState([]); - const sampleSites = surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []; + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[1] + }); + const [sortModel] = useState([]); + + const sampleSiteDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.getSampleSites(surveyContext.projectId, surveyContext.surveyId, { pagination }) + ); + + const pagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sortModel); + + return { + limit: paginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + + // API pagination pages begin at 1, but MUI DataGrid pagination begins at 0. + page: paginationModel.page + 1 + }; + }, [sortModel, paginationModel]); + + // Refresh survey list when pagination changes + useEffect(() => { + sampleSiteDataLoader.refresh(pagination); + + // Adding a DataLoader as a dependency causes an infinite rerender loop if a useEffect calls `.refresh` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pagination]); + + const sampleSites = sampleSiteDataLoader.data?.sampleSites ?? []; const handleSampleSiteMenuClick = ( event: React.MouseEvent, @@ -67,7 +101,7 @@ export const SamplingSiteListContainer = () => { .then(() => { dialogContext.setYesNoDialog({ open: false }); setSampleSiteAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + sampleSiteDataLoader.refresh(pagination); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -133,7 +167,7 @@ export const SamplingSiteListContainer = () => { dialogContext.setYesNoDialog({ open: false }); setCheckboxSelectedIds([]); setHeaderAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + sampleSiteDataLoader.refresh(pagination); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -155,6 +189,14 @@ export const SamplingSiteListContainer = () => { }); }; + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setPaginationModel({ page: 0, pageSize: parseInt(event.target.value, 10) }); + }; + + const handleChangePage = (_: React.MouseEvent | null, newPage: number) => { + setPaginationModel((model) => ({ ...model, page: newPage })); + }; + const handlePromptConfirmBulkDelete = () => { dialogContext.setYesNoDialog({ dialogTitle: 'Delete Sampling Sites?', @@ -179,7 +221,10 @@ export const SamplingSiteListContainer = () => { }); }; - const samplingSiteCount = sampleSites.length ?? 0; + const samplingSiteCount = useMemo( + () => sampleSiteDataLoader.data?.pagination.total ?? 0, + [sampleSiteDataLoader.data] + ); return ( <> @@ -287,102 +332,118 @@ export const SamplingSiteListContainer = () => { + + + + Select All + + } + control={ + 0 && checkboxSelectedIds.length === samplingSiteCount} + indeterminate={checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < samplingSiteCount} + onClick={() => { + if (checkboxSelectedIds.length === samplingSiteCount) { + setCheckboxSelectedIds([]); + return; + } + + const sampleSiteIds = sampleSites.map((sampleSite) => sampleSite.survey_sample_site_id); + setCheckboxSelectedIds(sampleSiteIds); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + } + /> + + + - - {surveyContext.sampleSiteDataLoader.isLoading ? ( + {sampleSiteDataLoader.isLoading ? ( + - ) : ( - - - - - Select All - - } - control={ - 0 && checkboxSelectedIds.length === samplingSiteCount} - indeterminate={ - checkboxSelectedIds.length >= 1 && checkboxSelectedIds.length < samplingSiteCount - } - onClick={() => { - if (checkboxSelectedIds.length === samplingSiteCount) { - setCheckboxSelectedIds([]); - return; - } + + ) : ( + + + {/* Display text if the sample site data loader has no items in it */} + {!sampleSiteDataLoader.data?.sampleSites.length && ( + + No Sampling Sites + + )} - const sampleSiteIds = sampleSites.map((sampleSite) => sampleSite.survey_sample_site_id); - setCheckboxSelectedIds(sampleSiteIds); - }} - inputProps={{ 'aria-label': 'controlled' }} - /> - } + {sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { + return ( + - - - - - {/* Display text if the sample site data loader has no items in it */} - {!surveyContext.sampleSiteDataLoader.data?.sampleSites.length && ( - - No Sampling Sites - - )} - - {surveyContext.sampleSiteDataLoader.data?.sampleSites.map((sampleSite) => { - return ( - - ); - })} - - {/* TODO how should we handle controlling pagination? */} - {/* - - {}} - rowsPerPageOptions={[10, 50]} - count={69} - /> - */} - - )} - + ); + })} + + + )} + {/* Pagination control */} + + + + ); diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx deleted file mode 100644 index fee52ec6e2..0000000000 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { mdiArrowRightThin, mdiCalendarRange } from '@mdi/js'; -import Icon from '@mdi/react'; -import { Timeline, TimelineConnector, TimelineContent, TimelineDot, TimelineItem, TimelineSeparator } from '@mui/lab'; -import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; -import Typography from '@mui/material/Typography'; -import { IObservationsContext } from 'contexts/observationsContext'; -import { IObservationsPageContext } from 'contexts/observationsPageContext'; -import dayjs from 'dayjs'; -import { ImportObservationsButton } from 'features/surveys/observations/sampling-sites/components/ImportObservationsButton'; -import { ISurveySampleMethodPeriodData } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; -import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; - -interface ISamplingSiteListPeriodProps { - samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; - observationsPageContext?: IObservationsPageContext; - observationsContext?: IObservationsContext; -} -/** - * Renders sampling periods for a sampling method - * @param props {ISamplingSiteListPeriodProps} - * @returns - */ -export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { - const formatDate = (dt: Date, time: boolean) => dayjs(dt).format(time ? 'MMM D, YYYY h:mm A' : 'MMM D, YYYY'); - - const { observationsPageContext, observationsContext } = props; - - const dateSx = { - fontSize: '0.85rem', - color: 'textSecondary' - }; - - const timeSx = { - fontSize: '0.85rem', - color: 'text.secondary' - }; - - return ( - - {props.samplePeriods - .sort((a, b) => { - const startDateA = new Date(a.start_date); - const startDateB = new Date(b.start_date); - - if (startDateA === startDateB) { - if (a.start_time && b.start_time) { - if (a.start_time < b.start_time) return 1; - if (a.start_time > b.start_time) return -1; - return 0; - } - if (a.start_time && !b.start_time) { - return -1; - } - } - if (startDateA < startDateB) { - return -1; - } - if (startDateA > startDateB) { - return 1; - } - return 0; - }) - .map((samplePeriod, index) => ( - - - {props.samplePeriods.length > 1 ? ( - - - {index < props.samplePeriods.length - 1 && ( - - )} - - ) : ( - - - - )} - - - - - - {formatDate(samplePeriod.start_date as unknown as Date, false)} - - - {samplePeriod.start_time} - - - - - - - - {formatDate(samplePeriod.end_date as unknown as Date, false)} - - - {samplePeriod.end_time} - - - {observationsPageContext && observationsContext && samplePeriod?.survey_sample_period_id && ( - - { - observationsPageContext.setIsDisabled(true); - observationsPageContext.setIsLoading(true); - }} - onSuccess={() => { - observationsContext.observationsDataLoader.refresh(); - }} - onFinish={() => { - observationsPageContext.setIsDisabled(false); - observationsPageContext.setIsLoading(false); - }} - processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} - /> - - )} - - - - ))} - - ); -}; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx b/app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx similarity index 64% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx rename to app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx index 8fc7756283..402dd1934d 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListSite.tsx +++ b/app/src/features/surveys/observations/sampling-sites/site/SamplingSiteListSite.tsx @@ -5,20 +5,15 @@ import AccordionDetails from '@mui/material/AccordionDetails'; import AccordionSummary from '@mui/material/AccordionSummary'; import Box from '@mui/material/Box'; import Checkbox from '@mui/material/Checkbox'; -import blue from '@mui/material/colors/blue'; import grey from '@mui/material/colors/grey'; import IconButton from '@mui/material/IconButton'; -import List from '@mui/material/List'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import { IStaticLayer } from 'components/map/components/StaticLayers'; -import { SamplingSiteListMethod } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListMethod'; -import { SamplingStratumChips } from 'features/surveys/sampling-information/sites/edit/form/SamplingStratumChips'; -import SurveyMap from 'features/surveys/view/SurveyMap'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { SamplingSiteListContent } from './accordion-details/SamplingSiteListContent'; export interface ISamplingSiteListSiteProps { - sampleSite: IGetSampleLocationDetails; + sampleSite: IGetSampleLocationNonSpatialDetails; isChecked: boolean; handleSampleSiteMenuClick: (event: React.MouseEvent, sample_site_id: number) => void; handleCheckboxChange: (sampleSiteId: number) => void; @@ -33,24 +28,10 @@ export interface ISamplingSiteListSiteProps { export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { const { sampleSite, isChecked, handleSampleSiteMenuClick, handleCheckboxChange } = props; - const staticLayers: IStaticLayer[] = [ - { - layerName: 'Sample Sites', - layerOptions: { color: blue[500], fillColor: blue[500] }, - features: [ - { - id: sampleSite.survey_sample_site_id, - key: `sampling-site-${sampleSite.survey_sample_site_id}`, - geoJSON: sampleSite.geojson - } - ] - } - ]; - let icon; - if (sampleSite.geojson.geometry.type === 'Point') { + if (sampleSite.geometry_type === 'Point') { icon = { path: mdiMapMarker, title: 'Point sampling site' }; - } else if (sampleSite.geojson.geometry.type === 'LineString') { + } else if (sampleSite.geometry_type === 'LineString') { icon = { path: mdiVectorLine, title: 'Transect sampling site' }; } else { icon = { path: mdiVectorSquare, title: 'Polygon sampling site' }; @@ -60,6 +41,12 @@ export const SamplingSiteListSite = (props: ISamplingSiteListSiteProps) => { { handleSampleSiteMenuClick(event, sampleSite.survey_sample_site_id) } aria-label="sample-site-settings"> - + { pb: 1, pl: 1, pr: 0 - }}> - {sampleSite.stratums && sampleSite.stratums.length > 0 && ( - - - - )} - - {sampleSite.sample_methods?.map((sampleMethod, index) => { - return ( - - ); - })} - - - - - + }} + /> + ); }; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx new file mode 100644 index 0000000000..41f48384de --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/SamplingSiteListContent.tsx @@ -0,0 +1,80 @@ +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import Skeleton from '@mui/material/Skeleton'; +import Stack from '@mui/material/Stack'; +import { SamplingStratumChips } from 'features/surveys/sampling-information/sites/edit/form/SamplingStratumChips'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; +import { SamplingSiteListMap } from './map/SamplingSiteMap'; +import { SamplingSiteListMethod } from './method/SamplingSiteListMethod'; + +export interface ISamplingSiteListContentProps { + surveySampleSiteId: number; +} + +/** + * Renders a list item for a single sampling method. + * + * @param {ISamplingSiteListContentProps} props + * @return {*} + */ +export const SamplingSiteListContent = (props: ISamplingSiteListContentProps) => { + const { surveySampleSiteId } = props; + + const biohubApi = useBiohubApi(); + const { surveyId, projectId } = useSurveyContext(); + + const sampleSiteDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) + ); + + useEffect(() => { + sampleSiteDataLoader.load(); + }, [sampleSiteDataLoader]); + + const sampleSite = sampleSiteDataLoader.data; + + if (!sampleSite) { + return ( + + + + + + + ); + } + + return ( + <> + {sampleSite.stratums && sampleSite.stratums.length > 0 && ( + + + + )} + + {sampleSite.sample_methods?.map((sampleMethod, index) => { + return ( + + ); + })} + + + + + + ); +}; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx new file mode 100644 index 0000000000..1756c6e8c7 --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/map/SamplingSiteMap.tsx @@ -0,0 +1,34 @@ +import blue from '@mui/material/colors/blue'; +import { IStaticLayer } from 'components/map/components/StaticLayers'; +import SurveyMap from 'features/surveys/view/SurveyMap'; +import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; + +export interface ISamplingSiteListMapProps { + sampleSite: IGetSampleLocationDetails; +} + +/** + * Renders a list item for a single sampling site. + * + * @param {ISamplingSiteListMapProps} props + * @return {*} + */ +export const SamplingSiteListMap = (props: ISamplingSiteListMapProps) => { + const { sampleSite } = props; + + const staticLayers: IStaticLayer[] = [ + { + layerName: 'Sample Sites', + layerOptions: { color: blue[500], fillColor: blue[500] }, + features: [ + { + id: sampleSite.survey_sample_site_id, + key: `sampling-site-${sampleSite.survey_sample_site_id}`, + geoJSON: sampleSite.geojson + } + ] + } + ]; + + return ; +}; diff --git a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx similarity index 95% rename from app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx rename to app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx index 39be47b8e5..448ea43903 100644 --- a/app/src/features/surveys/observations/sampling-sites/components/SamplingSiteListMethod.tsx +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/SamplingSiteListMethod.tsx @@ -2,7 +2,7 @@ import grey from '@mui/material/colors/grey'; import List from '@mui/material/List'; import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; -import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/components/SamplingSiteListPeriod'; +import { SamplingSiteListPeriod } from 'features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod'; import { useObservationsContext, useObservationsPageContext } from 'hooks/useContext'; import { IGetSampleMethodDetails } from 'interfaces/useSamplingSiteApi.interface'; diff --git a/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx new file mode 100644 index 0000000000..8371f5418c --- /dev/null +++ b/app/src/features/surveys/observations/sampling-sites/site/accordion-details/method/period/SamplingSiteListPeriod.tsx @@ -0,0 +1,143 @@ +import { mdiArrowRightThin, mdiCalendarRange } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Timeline, TimelineConnector, TimelineContent, TimelineDot, TimelineItem, TimelineSeparator } from '@mui/lab'; +import Box from '@mui/material/Box'; +import grey from '@mui/material/colors/grey'; +import Typography from '@mui/material/Typography'; +import { IObservationsContext } from 'contexts/observationsContext'; +import { IObservationsPageContext } from 'contexts/observationsPageContext'; +import dayjs from 'dayjs'; +import { ImportObservationsButton } from 'features/surveys/observations/sampling-sites/components/ImportObservationsButton'; +import { ISurveySampleMethodPeriodData } from 'features/surveys/sampling-information/periods/SamplingPeriodFormContainer'; +import { IGetSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; + +interface ISamplingSiteListPeriodProps { + samplePeriods: (IGetSamplePeriodRecord | ISurveySampleMethodPeriodData)[]; + observationsPageContext?: IObservationsPageContext; + observationsContext?: IObservationsContext; +} +/** + * Renders sampling periods for a sampling method + * @param props {ISamplingSiteListPeriodProps} + * @returns + */ +export const SamplingSiteListPeriod = (props: ISamplingSiteListPeriodProps) => { + const formatDate = (dt: Date, time: boolean) => dayjs(dt).format(time ? 'MMM D, YYYY h:mm A' : 'MMM D, YYYY'); + + const { observationsPageContext, observationsContext } = props; + + const dateSx = { + fontSize: '0.85rem', + color: 'textSecondary' + }; + + const timeSx = { + fontSize: '0.85rem', + color: 'text.secondary' + }; + + const sortedSamplePeriods = props.samplePeriods.sort((a, b) => { + const startDateA = new Date(a.start_date); + const startDateB = new Date(b.start_date); + + if (startDateA === startDateB) { + if (a.start_time && b.start_time) { + return a.start_time < b.start_time ? 1 : -1; + } + return a.start_time ? -1 : 1; + } + + return startDateA < startDateB ? -1 : 1; + }); + + return ( + + {sortedSamplePeriods.map((samplePeriod, index) => ( + + + {props.samplePeriods.length > 1 ? ( + + + {index < props.samplePeriods.length - 1 && ( + + )} + + ) : ( + + + + )} + + + + + + {formatDate(samplePeriod.start_date as unknown as Date, false)} + + + {samplePeriod.start_time} + + + + + + + + {formatDate(samplePeriod.end_date as unknown as Date, false)} + + + {samplePeriod.end_time} + + + {observationsPageContext && observationsContext && samplePeriod?.survey_sample_period_id && ( + + { + observationsPageContext.setIsDisabled(true); + observationsPageContext.setIsLoading(true); + }} + onSuccess={() => { + observationsContext.observationsDataLoader.refresh(); + }} + onFinish={() => { + observationsPageContext.setIsDisabled(false); + observationsPageContext.setIsLoading(false); + }} + processOptions={{ surveySamplePeriodId: samplePeriod.survey_sample_period_id }} + /> + + )} + + + + ))} + + ); +}; diff --git a/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx index 7830445b02..1909bdbaf1 100644 --- a/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx +++ b/app/src/features/surveys/sampling-information/manage/SamplingSiteManagePage.tsx @@ -1,9 +1,11 @@ import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { SamplingSiteManageHeader } from 'features/surveys/sampling-information/manage/SamplingSiteManageHeader'; import { SamplingTechniqueContainer } from 'features/surveys/sampling-information/techniques/SamplingTechniqueContainer'; import { useProjectContext, useSurveyContext } from 'hooks/useContext'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import SamplingSiteContainer from '../sites/SamplingSiteContainer'; /** @@ -25,6 +27,7 @@ export const SamplingSiteManagePage = () => { /> + diff --git a/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx b/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx index 5c435e414d..429da93169 100644 --- a/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx +++ b/app/src/features/surveys/sampling-information/methods/SamplingMethodFormContainer.tsx @@ -27,7 +27,9 @@ import { SamplingPeriodFormContainer } from 'features/surveys/sampling-informati import { ICreateSampleSiteFormData } from 'features/surveys/sampling-information/sites/create/CreateSamplingSitePage'; import { IEditSampleSiteFormData } from 'features/surveys/sampling-information/sites/edit/EditSamplingSitePage'; import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect, useState } from 'react'; import { TransitionGroup } from 'react-transition-group'; import { getCodesName } from 'utils/Utils'; @@ -48,11 +50,21 @@ export const SamplingMethodFormContainer = () => { const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + const codesContext = useContext(CodesContext); useEffect(() => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); + const techniquesDataLoader = useDataLoader(() => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + techniquesDataLoader.load(); + }, [techniquesDataLoader]); + const handleMenuClick = (event: React.MouseEvent, index: number) => { setAnchorEl(event.currentTarget); setEditData({ data: values.sample_methods[index], index }); @@ -172,7 +184,7 @@ export const SamplingMethodFormContainer = () => { { - surveyContext.techniqueDataLoader.data?.techniques.find( + techniquesDataLoader.data?.techniques.find( (technique) => technique.method_technique_id === sampleMethod.technique.method_technique_id )?.name diff --git a/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx b/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx index 2a9f7b03f2..28d61e67cd 100644 --- a/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx +++ b/app/src/features/surveys/sampling-information/methods/components/SamplingMethodForm.tsx @@ -5,7 +5,9 @@ import AutocompleteField, { IAutocompleteFieldOption } from 'components/fields/A import CustomTextField from 'components/fields/CustomTextField'; import { CodesContext } from 'contexts/codesContext'; import { useFormikContext } from 'formik'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; import { useContext, useEffect } from 'react'; import yup from 'utils/YupSchema'; import { v4 } from 'uuid'; @@ -62,6 +64,8 @@ export const SamplingMethodForm = () => { const codesContext = useContext(CodesContext); const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + const { setFieldValue } = useFormikContext(); const methodResponseMetricOptions: IAutocompleteFieldOption[] = @@ -75,7 +79,13 @@ export const SamplingMethodForm = () => { codesContext.codesDataLoader.load(); }, [codesContext.codesDataLoader]); - const techniques = surveyContext.techniqueDataLoader.data?.techniques; + const techniquesDataLoader = useDataLoader(() => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + techniquesDataLoader.load(); + }, [techniquesDataLoader]); if (!codesContext.codesDataLoader.data) { return ; @@ -91,7 +101,7 @@ export const SamplingMethodForm = () => { label="Technique" name="technique.method_technique_id" options={ - techniques?.map((option) => ({ + techniquesDataLoader.data?.techniques.map((option) => ({ value: option.method_technique_id, label: option.name, subText: option.description ?? undefined diff --git a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx index ea9bcec0f8..bc3fa998ab 100644 --- a/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx +++ b/app/src/features/surveys/sampling-information/periods/table/SamplingPeriodTable.tsx @@ -1,61 +1,66 @@ import Typography from '@mui/material/Typography'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridRowSelectionModel, GridSortModel } from '@mui/x-data-grid'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import dayjs from 'dayjs'; import { useCodesContext } from 'hooks/useContext'; +import { IFindSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; +import { formatTimeDifference } from 'utils/datetime'; import { getCodesName } from 'utils/Utils'; -export interface ISamplingSitePeriodRowData { - id: number; - sample_site: string; - sample_method: string; - method_response_metric_id: number; - start_date: string; - end_date: string; - start_time: string | null; - end_time: string | null; -} - interface ISamplingPeriodTableProps { - periods: ISamplingSitePeriodRowData[]; + periods: IFindSamplePeriodRecord[]; + selectedRows: GridRowSelectionModel; + setSelectedRows: (selection: GridRowSelectionModel) => void; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + sortModel: GridSortModel; + setSortModel: React.Dispatch>; + pageSizeOptions: number[]; + rowCount: number; } /** * Renders a table of sampling periods. * - * @param props {} + * @param {ISamplingPeriodTableProps} props * @returns {*} */ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { - const { periods } = props; + const { periods, paginationModel, setPaginationModel, sortModel, setSortModel, pageSizeOptions, rowCount } = props; const codesContext = useCodesContext(); - const columns: GridColDef[] = [ + const columns: GridColDef[] = [ { field: 'sample_site', headerName: 'Site', - flex: 1 + flex: 1, + valueGetter: (params) => { + return params.row.sample_site.name; + } }, { field: 'sample_method', headerName: 'Technique', - flex: 1 + flex: 1, + valueGetter: (params) => { + return params.row.method_technique.name; + } }, { field: 'method_response_metric_id', headerName: 'Response Metric', flex: 1, - renderCell: (params) => ( - <> - {getCodesName( - codesContext.codesDataLoader.data, - 'method_response_metrics', - params.row.method_response_metric_id - )} - - ) + valueGetter: (params) => { + const value = getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + params.row.sample_method.method_response_metric_id + ); + + return value; + } }, { field: 'start_date', @@ -82,25 +87,47 @@ export const SamplingPeriodTable = (props: ISamplingPeriodTableProps) => { field: 'end_time', headerName: 'End time', flex: 1 + }, + { + field: 'duration', + headerName: 'Duration', + flex: 1, + valueGetter: (params) => { + const { start_date, start_time, end_date, end_time } = params.row; + + if (!start_date || !end_date) { + return null; + } + + return formatTimeDifference(start_date, start_time, end_date, end_time); + } } ]; return ( 'auto'} disableColumnMenu + rowSelection={false} + autoHeight={false} + getRowHeight={() => 'auto'} rows={periods} - getRowId={(row: ISamplingSitePeriodRowData) => row.id} + getRowId={(row: IFindSamplePeriodRecord) => row.survey_sample_period_id} columns={columns} checkboxSelection={false} disableRowSelectionOnClick + rowCount={rowCount} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); }; diff --git a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx index f8d28ab0f3..8785c0693e 100644 --- a/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx +++ b/app/src/features/surveys/sampling-information/sites/SamplingSiteContainer.tsx @@ -1,33 +1,23 @@ -import { mdiArrowTopRight, mdiDotsVertical, mdiPlus, mdiTrashCanOutline } from '@mdi/js'; +import { mdiPlus } from '@mdi/js'; import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; -import IconButton from '@mui/material/IconButton'; -import ListItemIcon from '@mui/material/ListItemIcon'; -import ListItemText from '@mui/material/ListItemText'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; -import { GridRowSelectionModel } from '@mui/x-data-grid'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; import { LoadingGuard } from 'components/loading/LoadingGuard'; import { SkeletonMap, SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; -import { - ISamplingSitePeriodRowData, - SamplingPeriodTable -} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; -import { SamplingSiteMapContainer } from 'features/surveys/sampling-information/sites/map/SamplingSiteMapContainer'; -import { SamplingSiteTable } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; -import { - SamplingSiteManageTableView, - SamplingSiteTabs -} from 'features/surveys/sampling-information/sites/table/SamplingSiteTabs'; +import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import { useBiohubApi } from 'hooks/useBioHubApi'; -import { useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { useEffect, useMemo, useState } from 'react'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; +import { useEffect } from 'react'; import { Link as RouterLink } from 'react-router-dom'; +import { SamplingSiteTableContainer } from './table/SamplingSiteTableContainer'; /** * Component for managing sampling sites, methods, and periods. @@ -37,220 +27,58 @@ import { Link as RouterLink } from 'react-router-dom'; */ const SamplingSiteContainer = () => { const surveyContext = useSurveyContext(); - const dialogContext = useDialogContext(); - const biohubApi = useBiohubApi(); - // State for bulk actions - const [headerAnchorEl, setHeaderAnchorEl] = useState(null); - const [siteSelection, setSiteSelection] = useState([]); + const biohubApi = useBiohubApi(); - // Controls whether sites, methods, or periods are shown - const [activeView, setActiveView] = useState(SamplingSiteManageTableView.SITES); + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); - const sampleSites = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] + const techniquesDataLoader = useDataLoader(() => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId) ); - const sampleSiteCount = surveyContext.sampleSiteDataLoader.data?.pagination.total ?? 0; - - const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { - const data: ISamplingSitePeriodRowData[] = []; - - for (const site of sampleSites) { - for (const method of site.sample_methods) { - for (const period of method.sample_periods) { - data.push({ - id: period.survey_sample_period_id, - sample_site: site.name, - sample_method: method.technique.name, - method_response_metric_id: method.method_response_metric_id, - start_date: period.start_date, - end_date: period.end_date, - start_time: period.start_time, - end_time: period.end_time - }); - } - } - } - - return data; - }, [sampleSites]); useEffect(() => { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - }, [surveyContext.sampleSiteDataLoader, surveyContext.projectId, surveyContext.surveyId]); - - // Handler for bulk delete operation - const handleBulkDelete = async () => { - try { - await biohubApi.samplingSite.deleteSampleSites( - surveyContext.projectId, - surveyContext.surveyId, - siteSelection.map((site) => Number(site)) // Convert GridRowId to number[] - ); - dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog - setSiteSelection([]); // Clear selection - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); // Refresh data - } catch (error) { - dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog on error - setSiteSelection([]); // Clear selection - // Show snackbar with error message - dialogContext.setSnackbar({ - snackbarMessage: ( - <> - - Error Deleting Items - - - {String(error)} - - - ), - open: true - }); - } - }; - - // Handler for clicking on header menu (bulk actions) - const handleHeaderMenuClick = (event: React.MouseEvent) => { - setHeaderAnchorEl(event.currentTarget); - }; - - // Handler for confirming bulk delete operation - const handlePromptConfirmBulkDelete = () => { - setHeaderAnchorEl(null); // Close header menu - dialogContext.setYesNoDialog({ - dialogTitle: 'Delete Sampling Sites?', - dialogContent: ( - - Are you sure you want to delete the selected sampling sites? - - ), - yesButtonLabel: 'Delete Sampling Sites', - noButtonLabel: 'Cancel', - yesButtonProps: { color: 'error' }, - onClose: () => dialogContext.setYesNoDialog({ open: false }), - onNo: () => dialogContext.setYesNoDialog({ open: false }), - open: true, - onYes: handleBulkDelete - }); - }; - - // Counts for the toggle button labels - const viewCounts = { - [SamplingSiteManageTableView.SITES]: sampleSiteCount, - [SamplingSiteManageTableView.PERIODS]: samplePeriods.length - }; + techniquesDataLoader.load(); + }, [techniquesDataLoader]); return ( <> - {/* Bulk action menu */} - setHeaderAnchorEl(null)} - anchorEl={headerAnchorEl} - anchorOrigin={{ vertical: 'top', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }}> - - - - - Delete - - - - Sampling Sites ‌ - - ({sampleSiteCount}) - + Sampling Sites - - - - + + + + + - + } isLoadingFallbackDelay={100}> - - - {/* Toggle buttons for changing between sites, methods, and periods */} - - - - - {/* Data tables */} - - {activeView === SamplingSiteManageTableView.SITES && ( - } - isLoadingFallbackDelay={100} - hasNoData={!viewCounts[SamplingSiteManageTableView.SITES]} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - )} - - {activeView === SamplingSiteManageTableView.PERIODS && ( - } - isLoadingFallbackDelay={100} - hasNoData={!viewCounts[SamplingSiteManageTableView.PERIODS]} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - )} + + + + ); }; diff --git a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx index 9085af7c82..04bf652355 100644 --- a/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx +++ b/app/src/features/surveys/sampling-information/sites/components/map/SamplingSiteMapControl.tsx @@ -22,6 +22,8 @@ import SampleSiteFileUploadItemProgressBar from 'features/surveys/sampling-infor import SampleSiteFileUploadItemSubtext from 'features/surveys/sampling-information/sites/components/map/file-upload/SampleSiteFileUploadItemSubtext'; import { FormikContextType } from 'formik'; import { Feature } from 'geojson'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; import { ICreateSamplingSiteRequest, ISurveySampleSite } from 'interfaces/useSamplingSiteApi.interface'; import { DrawEvents, LatLngBoundsExpression } from 'leaflet'; import 'leaflet-fullscreen/dist/leaflet.fullscreen.css'; @@ -65,6 +67,8 @@ export interface ISamplingSiteMapControlProps { const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { const classes = useStyles(); + const biohubApi = useBiohubApi(); + const surveyContext = useContext(SurveyContext); const [lastDrawn, setLastDrawn] = useState(null); @@ -74,7 +78,15 @@ const SamplingSiteMapControl = (props: ISamplingSiteMapControlProps) => { const { values, errors, setFieldValue, setFieldError } = formikProps; - let numSites = surveyContext.sampleSiteDataLoader.data?.sampleSites.length ?? 0; + const samplingSiteDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSitesGeometry(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + samplingSiteDataLoader.load(); + }, [samplingSiteDataLoader]); + + let numSites = samplingSiteDataLoader.data?.sampleSites.length ?? 0; const [updatedBounds, setUpdatedBounds] = useState(undefined); diff --git a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx index 454dcf5be8..318deeac1c 100644 --- a/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/create/CreateSamplingSitePage.tsx @@ -87,9 +87,6 @@ export const CreateSamplingSitePage = () => { await biohubApi.samplingSite.createSamplingSites(surveyContext.projectId, surveyContext.surveyId, data); - // Refresh the context, so the next page loads with the latest data - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // create complete, navigate back to observations page history.push( `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, diff --git a/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteCreateForm.tsx b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteCreateForm.tsx index 8b9be239b9..49384618b0 100644 --- a/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteCreateForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteCreateForm.tsx @@ -1,5 +1,6 @@ import LoadingButton from '@mui/lab/LoadingButton/LoadingButton'; import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; import Container from '@mui/material/Container'; import Divider from '@mui/material/Divider'; import Paper from '@mui/material/Paper'; @@ -14,7 +15,9 @@ import { SampleSiteImportForm } from 'features/surveys/sampling-information/site import { useFormikContext } from 'formik'; import { useSurveyContext } from 'hooks/useContext'; import { useHistory } from 'react-router'; +import { TransitionGroup } from 'react-transition-group'; import yup from 'utils/YupSchema'; +import SampleSiteGeneralInformationCreateForm from './SampleSiteGeneralInformationCreateForm'; export const SampleSiteCreateFormYupSchema = yup.object({ survey_sample_sites: yup @@ -56,22 +59,35 @@ const SampleSiteCreateForm = (props: ISampleSiteCreateFormProps) => { const { isSubmitting } = props; const history = useHistory(); - const { submitForm } = useFormikContext(); + const { submitForm, values } = useFormikContext(); const surveyContext = useSurveyContext(); return ( - - - - + + + - + + + + {values.survey_sample_sites.length === 1 && ( + + + + + + + )} + + diff --git a/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteGeneralInformationCreateForm.tsx b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteGeneralInformationCreateForm.tsx new file mode 100644 index 0000000000..76a18a018d --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/create/form/SampleSiteGeneralInformationCreateForm.tsx @@ -0,0 +1,43 @@ +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid'; +import TextField from '@mui/material/TextField'; +import { useFormikContext } from 'formik'; +import { ChangeEvent } from 'react'; + +/** + * Component for adding a name and description to a sampling site on the create form. + * + * NOTE: When creating a sampling site, the sites are an array and the name and description apply to each of the sites in the array. + * A name and description can only be manually entered when creating a single sampling site. If creating multiple, + * the name and description are taken from the file or default to "Samping Site i" and no description. + * + * @return {*} + */ +const SampleSiteGeneralInformationCreateForm = () => { + const { setFieldValue } = useFormikContext(); + + const handleNameChange = (event: ChangeEvent) => { + const newValue = event.target.value; + setFieldValue('survey_sample_sites[0].name', newValue); + }; + + const handleDescriptionChange = (event: ChangeEvent) => { + const newValue = event.target.value; + setFieldValue('survey_sample_sites[0].description', newValue); + }; + + return ( + + + + + + + + + + + ); +}; + +export default SampleSiteGeneralInformationCreateForm; diff --git a/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx index f82a081ed7..43697c682e 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/EditSamplingSitePage.tsx @@ -33,7 +33,7 @@ export interface IEditSampleSiteFormData { survey_sample_site_id: number | null; survey_id: number; name: string; - description: string; + description: string | null; geojson: Feature; sample_methods: (IGetSampleMethodDetails | ISurveySampleMethodFormData)[]; blocks: IGetSampleBlockDetails[]; @@ -105,7 +105,7 @@ export const EditSamplingSitePage = () => { const editSampleSite: IEditSampleSiteRequest = { sampleSite: { name: values.name, - description: values.description, + description: values.description ?? '', survey_id: values.survey_id, survey_sample_sites: [values.geojson as Feature], geojson: values.geojson, @@ -128,15 +128,11 @@ export const EditSamplingSitePage = () => { .then(() => { setIsSubmitting(false); - // Refresh the context, so the next page loads with the latest data - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // create complete, navigate back to observations page history.push( `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, SKIP_CONFIRMATION_DIALOG ); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); diff --git a/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteEditForm.tsx b/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteEditForm.tsx index 1aa50694f9..aecb157b73 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteEditForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteEditForm.tsx @@ -15,7 +15,7 @@ import { useContext } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import yup from 'utils/YupSchema'; import SurveySamplingSiteEditForm from '../../components/map/SurveySampleSiteEditForm'; -import SampleSiteGeneralInformationForm from './SampleSiteGeneralInformationForm'; +import SampleSiteGeneralInformationEditForm from './SampleSiteGeneralInformationForm'; export const SampleSiteEditFormYupSchema = yup.object({ name: yup.string().default('').min(1, 'Minimum 1 character.').max(50, 'Maximum 50 characters.'), @@ -50,7 +50,7 @@ const SampleSiteEditForm = (props: ISampleSiteEditFormProps) => { - + diff --git a/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteGeneralInformationForm.tsx b/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteGeneralInformationForm.tsx index f6109f5e45..27a968011f 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteGeneralInformationForm.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/form/SampleSiteGeneralInformationForm.tsx @@ -1,15 +1,15 @@ +import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; import CustomTextField from 'components/fields/CustomTextField'; -import React from 'react'; /** * Create survey - general information fields * * @return {*} */ -const SampleSiteGeneralInformationForm: React.FC = () => { +const SampleSiteGeneralInformationEditForm = () => { return ( - <> + @@ -18,8 +18,8 @@ const SampleSiteGeneralInformationForm: React.FC = () => { - + ); }; -export default SampleSiteGeneralInformationForm; +export default SampleSiteGeneralInformationEditForm; diff --git a/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx b/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx index fe02b40205..71267aa83e 100644 --- a/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx +++ b/app/src/features/surveys/sampling-information/sites/edit/form/SamplingStratumChips.tsx @@ -1,12 +1,12 @@ import { blue, cyan, orange, pink, purple, teal } from '@mui/material/colors'; import Stack from '@mui/material/Stack'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleStratumDetails } from 'interfaces/useSamplingSiteApi.interface'; const SAMPLING_SITE_CHIP_COLOURS = [purple, blue, pink, teal, cyan, orange]; interface ISamplingStratumChipsProps { - sampleSite: IGetSampleLocationDetails; + stratums: IGetSampleStratumDetails[]; } /** @@ -18,9 +18,9 @@ interface ISamplingStratumChipsProps { export const SamplingStratumChips = (props: ISamplingStratumChipsProps) => { return ( - {props.sampleSite.stratums.map((stratum, index) => ( + {props.stratums.map((stratum, index) => ( { - const staticLayers: IStaticLayer[] = props.samplingSites.map((sampleSite) => ({ - layerName: 'Sample Sites', - layerOptions: { color: blue[500], fillColor: blue[500] }, - features: [ - { - id: sampleSite.survey_sample_site_id, - key: `sample-site-${sampleSite.survey_sample_site_id}`, - geoJSON: sampleSite.geojson - } - ] - })); - - return ( - - - - ); -}; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx index 8d446333a9..68650577b5 100644 --- a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTable.tsx @@ -8,13 +8,11 @@ import ListItemText from '@mui/material/ListItemText'; import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Typography from '@mui/material/Typography'; -import { GridColDef, GridRowSelectionModel } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridRowSelectionModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { Feature } from 'geojson'; -import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useSurveyContext } from 'hooks/useContext'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; @@ -23,44 +21,73 @@ export interface ISamplingSiteRowData { id: number; name: string; description: string; - geojson: Feature; + geometry_type: string; blocks: string[]; stratums: string[]; } interface ISamplingSiteTableProps { - sites: IGetSampleLocationDetails[]; - bulkActionSites: GridRowSelectionModel; - setBulkActionSites: (selection: GridRowSelectionModel) => void; + sites: IGetSampleLocationNonSpatialDetails[]; + selectedRows: GridRowSelectionModel; + setSelectedRows: (selection: GridRowSelectionModel) => void; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + sortModel: GridSortModel; + setSortModel: React.Dispatch>; + pageSizeOptions: number[]; + rowCount: number; + /** + * Callback fired when the delete action is triggered. + */ + onDelete: (sampleSiteId: number) => Promise; } /** * Returns a table of sampling sites with edit actions * - * @param props {} + * @param {ISamplingSiteTableProps} props * @returns {*} */ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { - const { sites, bulkActionSites, setBulkActionSites } = props; + const { + sites, + selectedRows, + setSelectedRows, + paginationModel, + setPaginationModel, + sortModel, + setSortModel, + pageSizeOptions, + rowCount, + onDelete + } = props; - const biohubApi = useBiohubApi(); const surveyContext = useSurveyContext(); const dialogContext = useDialogContext(); - const [actionMenuSite, setActionMenuSite] = useState(); - const [actionMenuAnchorEl, setActionMenuAnchorEl] = useState(null); + const [actionMenuAnchorEl, setActionMenuAnchorEl] = useState<{ + anchorEl: MenuProps['anchorEl']; + sampleSiteId: number; + } | null>(null); const handleCloseActionMenu = () => { setActionMenuAnchorEl(null); }; - const handleDeleteSamplingSite = async () => { - await biohubApi.samplingSite - .deleteSampleSite(surveyContext.projectId, surveyContext.surveyId, Number(actionMenuSite)) + /** + * Handle the delete action. + * + * @return {*} + */ + const handleDelete = async () => { + if (!actionMenuAnchorEl) { + return; + } + + await onDelete(actionMenuAnchorEl.sampleSiteId) .then(() => { dialogContext.setYesNoDialog({ open: false }); setActionMenuAnchorEl(null); - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -85,7 +112,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { * Display the delete samplingSite dialog. * */ - const deleteSamplingSiteDialog = () => { + const handlePromptConfirmDelete = () => { dialogContext.setYesNoDialog({ dialogTitle: 'Delete sampling site?', dialogText: 'Are you sure you want to permanently delete this sampling site?', @@ -100,7 +127,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { }, open: true, onYes: () => { - handleDeleteSamplingSite(); + handleDelete(); } }); }; @@ -108,8 +135,8 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { const rows: ISamplingSiteRowData[] = sites.map((site) => ({ id: site.survey_sample_site_id, name: site.name, + geometry_type: site.geometry_type, description: site.description || '', - geojson: site.geojson, blocks: site.blocks.map((block) => block.name), stratums: site.stratums.map((stratum) => stratum.name) })); @@ -123,11 +150,11 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { { field: 'geometry_type', headerName: 'Geometry', - flex: 1, + flex: 0.75, renderCell: (params) => ( @@ -138,11 +165,10 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { headerName: 'Description', flex: 1 }, - { field: 'blocks', headerName: 'Blocks', - flex: 1, + flex: 0.75, renderCell: (params) => ( {params.row.blocks.map((block) => ( @@ -156,11 +182,11 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { { field: 'stratums', headerName: 'Strata', - flex: 1, + flex: 0.75, renderCell: (params) => ( {params.row.stratums.map((stratum) => ( - + ))} @@ -175,15 +201,12 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { align: 'right', renderCell: (params) => { return ( - - { - setActionMenuSite(params.row.id); - setActionMenuAnchorEl(event.currentTarget); - }}> - - - + { + setActionMenuAnchorEl({ anchorEl: event.currentTarget, sampleSiteId: params.row.id }); + }}> + + ); } } @@ -195,7 +218,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { { } }}> + to={`/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling/${actionMenuAnchorEl?.sampleSiteId}/edit`}> @@ -224,7 +247,7 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { { handleCloseActionMenu(); - deleteSamplingSiteDialog(); + handlePromptConfirmDelete(); }}> @@ -235,21 +258,29 @@ export const SamplingSiteTable = (props: ISamplingSiteTableProps) => { {/* DATA TABLE */} 'auto'} disableColumnMenu rows={rows} getRowId={(row: ISamplingSiteRowData) => row.id} columns={columns} - rowSelectionModel={bulkActionSites} - onRowSelectionModelChange={setBulkActionSites} + rowSelectionModel={selectedRows} + onRowSelectionModelChange={setSelectedRows} checkboxSelection + disableRowSelectionOnClick + rowCount={rowCount} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx new file mode 100644 index 0000000000..80535ca85e --- /dev/null +++ b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTableContainer.tsx @@ -0,0 +1,325 @@ +import { mdiArrowTopRight, mdiCalendarRange, mdiDotsVertical, mdiMapMarker, mdiTrashCanOutline } from '@mdi/js'; +import Icon from '@mdi/react'; +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import { GridPaginationModel, GridRowSelectionModel, GridSortModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; +import { SamplingPeriodTable } from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import { SamplingSiteTable } from './SamplingSiteTable'; + +const pageSizeOptions = [10, 25, 50]; + +export enum SamplingViews { + SITES = 'SITES', + PERIODS = 'PERIODS' +} + +export interface ISamplingPeriodRowData { + id: number; + sample_site: string; + sample_method: string; + method_response_metric_id: number; + start_date: string; + end_date: string; + start_time: string | null; + end_time: string | null; +} + +/** + * Returns a table of sampling sites with edit actions + * + * @returns {*} + */ +export const SamplingSiteTableContainer = () => { + const dialogContext = useDialogContext(); + const surveyContext = useSurveyContext(); + + const biohubApi = useBiohubApi(); + + // Action menu + const [headerAnchorEl, setHeaderAnchorEl] = useState(null); + + // Views + const [activeView, setActiveView] = useState(SamplingViews.SITES); + + const views = [ + { value: SamplingViews.SITES, label: 'Sampling Sites', icon: mdiMapMarker }, + { value: SamplingViews.PERIODS, label: 'Sampling Periods', icon: mdiCalendarRange } + ]; + + // Sites + const [selectedSites, setSelectedSites] = useState([]); + + const [sitesPaginationModel, setSitesPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + + const [sitesSortModel, setSitesSortModel] = useState([]); + + const sitesPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sitesSortModel); + + return { + limit: sitesPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + + // API sitesPagination pages begin at 1, but MUI DataGrid sitesPagination begins at 0. + page: sitesPaginationModel.page + 1 + }; + }, [sitesSortModel, sitesPaginationModel]); + + const samplingSitesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.getSampleSites(surveyContext.projectId, surveyContext.surveyId, { pagination }) + ); + + // Periods + const [selectedPeriods, setSelectedPeriods] = useState([]); + + const [periodsPaginationModel, setPeriodsPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + + const [periodsSortModel, setPeriodsSortModel] = useState([]); + + const periodsPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(periodsSortModel); + return { + limit: periodsPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: periodsPaginationModel.page + 1 + }; + }, [periodsSortModel, periodsPaginationModel]); + + const samplingPeriodsDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.findSamplePeriods({ survey_id: surveyContext.surveyId }, pagination) + ); + + useEffect(() => { + // Refresh active view data loader when switching to the view for the first time + if (activeView === SamplingViews.SITES && !samplingSitesDataLoader.data) { + samplingSitesDataLoader.refresh(sitesPagination); + } + + if (activeView === SamplingViews.PERIODS && !samplingPeriodsDataLoader.data) { + samplingPeriodsDataLoader.refresh(periodsPagination); + } + // Including data loaders in the dependency array causes infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); + + useEffect(() => { + if (activeView === SamplingViews.SITES && Number(samplingSitesDataLoader.data?.pagination.total) !== 0) { + samplingSitesDataLoader.refresh(sitesPagination); + } + // Including data loaders in the dependency array causes infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sitesPagination]); + + useEffect(() => { + if (activeView === SamplingViews.PERIODS && Number(samplingPeriodsDataLoader.data?.pagination.total) !== 0) { + samplingPeriodsDataLoader.refresh(periodsPagination); + } + // Including data loaders in the dependency array causes infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [periodsPagination]); + + // Data + const sampleSites = samplingSitesDataLoader.data?.sampleSites ?? []; + const samplePeriods = samplingPeriodsDataLoader.data?.periods ?? []; + + // Handler for bulk delete operation + const handleBulkDelete = async () => { + try { + await biohubApi.samplingSite.deleteSampleSites( + surveyContext.projectId, + surveyContext.surveyId, + selectedSites.map((site) => Number(site)) // Convert GridRowId to number[] + ); + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog + setSelectedSites([]); // Clear selection + samplingSitesDataLoader.refresh(sitesPagination); // Refresh data + } catch (error) { + dialogContext.setYesNoDialog({ open: false }); // Close confirmation dialog on error + setSelectedSites([]); // Clear selection + // Show snackbar with error message + dialogContext.setSnackbar({ + snackbarMessage: ( + <> + + Error Deleting Items + + + {String(error)} + + + ), + open: true + }); + } + }; + + const handleDelete = async (sampleSiteId: number) => { + await biohubApi.samplingSite.deleteSampleSite(surveyContext.projectId, surveyContext.surveyId, sampleSiteId); + samplingSitesDataLoader.refresh(sitesPagination); // Refresh data + }; + + // Handler for clicking on header menu (bulk actions) + const handleHeaderMenuClick = (event: React.MouseEvent) => { + setHeaderAnchorEl(event.currentTarget); + }; + + // Handler for confirming bulk delete operation + const handlePromptConfirmBulkDelete = () => { + setHeaderAnchorEl(null); // Close header menu + dialogContext.setYesNoDialog({ + dialogTitle: 'Delete Sampling Sites?', + dialogContent: ( + + Are you sure you want to delete the selected sampling sites? + + ), + yesButtonLabel: 'Delete Sampling Sites', + noButtonLabel: 'Cancel', + yesButtonProps: { color: 'error' }, + onClose: () => dialogContext.setYesNoDialog({ open: false }), + onNo: () => dialogContext.setYesNoDialog({ open: false }), + open: true, + onYes: handleBulkDelete + }); + }; + + return ( + <> + {/* Bulk action menu */} + setHeaderAnchorEl(null)} + anchorEl={headerAnchorEl} + anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }}> + + + + + Delete + + + + + {/* Toggle buttons for changing between sites, methods, and periods */} + setActiveView(view)} + orientation="horizontal" + /> + + + + + + + + + {/* Data tables */} + + {activeView === SamplingViews.SITES && ( + } + isLoadingFallbackDelay={100} + hasNoData={!sampleSites.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + )} + + {activeView === SamplingViews.PERIODS && ( + } + isLoadingFallbackDelay={100} + hasNoData={!samplePeriods.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + )} + + + ); +}; diff --git a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx b/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx deleted file mode 100644 index f7060fb206..0000000000 --- a/app/src/features/surveys/sampling-information/sites/table/SamplingSiteTabs.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { mdiCalendarRange, mdiMapMarker } from '@mdi/js'; -import { Icon } from '@mdi/react'; -import Button from '@mui/material/Button'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import Toolbar from '@mui/material/Toolbar'; -import { SetStateAction } from 'react'; - -export enum SamplingSiteManageTableView { - SITES = 'SITES', - PERIODS = 'PERIODS' -} - -interface ISamplingSiteManageTableView { - value: SamplingSiteManageTableView; - icon: React.ReactNode; -} - -export type ISamplingSiteCount = Record; - -interface ISamplingSiteTabsProps { - activeView: SamplingSiteManageTableView; - setActiveView: React.Dispatch>; - viewCounts: Record; -} - -/** - * Renders tab controls for the sampling site table, which allow the user to switch between viewing sites and periods. - * - * @param {ISamplingSiteTabsProps} props - * @return {*} - */ -export const SamplingSiteTabs = (props: ISamplingSiteTabsProps) => { - const { activeView, setActiveView, viewCounts } = props; - - const views: ISamplingSiteManageTableView[] = [ - { value: SamplingSiteManageTableView.SITES, icon: }, - { value: SamplingSiteManageTableView.PERIODS, icon: } - ]; - - const activeViewCount = viewCounts[activeView]; - - const updateDatasetView = (_: React.MouseEvent, view: SamplingSiteManageTableView) => { - if (view) { - setActiveView(view); - } - }; - - return ( - - - {views.map((view) => ( - - {view.value} ({activeViewCount}) - - ))} - - - ); -}; diff --git a/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx index fe860d44ad..bd72badbad 100644 --- a/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx +++ b/app/src/features/surveys/sampling-information/techniques/SamplingTechniqueContainer.tsx @@ -12,11 +12,14 @@ import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; import { GridRowSelectionModel } from '@mui/x-data-grid'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; import { LoadingGuard } from 'components/loading/LoadingGuard'; import { SkeletonTable } from 'components/loading/SkeletonLoaders'; import { DeleteTechniquesBulkI18N } from 'constants/i18n'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useDialogContext, useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { useEffect, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { SamplingTechniqueTable } from './table/SamplingTechniqueTable'; @@ -33,29 +36,33 @@ export const SamplingTechniqueContainer = () => { const biohubApi = useBiohubApi(); // Multi-select row action menu - const [bulkActionTechniques, setBulkActionTechniques] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); const [bulkActionMenuAnchorEl, setBulkActionMenuAnchorEl] = useState(null); + const techniquesDataLoader = useDataLoader(() => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId) + ); + useEffect(() => { - surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + techniquesDataLoader.refresh(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [surveyContext.projectId, surveyContext.surveyId]); - const techniqueCount = surveyContext.techniqueDataLoader.data?.count ?? 0; - const techniques = surveyContext.techniqueDataLoader.data?.techniques ?? []; + const techniqueCount = techniquesDataLoader.data?.pagination.total ?? 0; + const techniques = techniquesDataLoader.data?.techniques ?? []; const handleBulkDeleteTechniques = async () => { await biohubApi.technique - .deleteTechniques(surveyContext.projectId, surveyContext.surveyId, bulkActionTechniques.map(Number)) + .deleteTechniques(surveyContext.projectId, surveyContext.surveyId, selectedRows.map(Number)) .then(() => { dialogContext.setYesNoDialog({ open: false }); - setBulkActionTechniques([]); + setSelectedRows([]); setBulkActionMenuAnchorEl(null); - surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); + techniquesDataLoader.refresh(); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); - setBulkActionTechniques([]); + setSelectedRows([]); setBulkActionMenuAnchorEl(null); dialogContext.setSnackbar({ snackbarMessage: ( @@ -73,6 +80,11 @@ export const SamplingTechniqueContainer = () => { }); }; + const handleDelete = async (techniqueId: number) => { + await biohubApi.technique.deleteTechnique(surveyContext.projectId, surveyContext.surveyId, techniqueId); + techniquesDataLoader.refresh(); + }; + const deleteBulkTechniquesDialog = () => { dialogContext.setYesNoDialog({ dialogTitle: DeleteTechniquesBulkI18N.deleteTitle, @@ -128,43 +140,47 @@ export const SamplingTechniqueContainer = () => { pl: 3 }}> - Techniques ‌ + Sampling Techniques ‌ ({techniqueCount}) - - setBulkActionMenuAnchorEl(event.currentTarget)} - title="Bulk Actions"> - - + + + + setBulkActionMenuAnchorEl(event.currentTarget)} + title="Bulk Actions"> + + + } isLoadingFallbackDelay={100}> - + diff --git a/app/src/features/surveys/sampling-information/techniques/create/CreateTechniquePage.tsx b/app/src/features/surveys/sampling-information/techniques/create/CreateTechniquePage.tsx index d57af2f4e6..93f0390ecd 100644 --- a/app/src/features/surveys/sampling-information/techniques/create/CreateTechniquePage.tsx +++ b/app/src/features/surveys/sampling-information/techniques/create/CreateTechniquePage.tsx @@ -85,9 +85,6 @@ export const CreateTechniquePage = () => { createTechniqueRequestData ]); - // Refresh the context, so the next page loads with the latest data - surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // Success, navigate back to the manage sampling information page history.push( `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, diff --git a/app/src/features/surveys/sampling-information/techniques/edit/EditTechniquePage.tsx b/app/src/features/surveys/sampling-information/techniques/edit/EditTechniquePage.tsx index 0bb5106fbb..90ffa8e8ad 100644 --- a/app/src/features/surveys/sampling-information/techniques/edit/EditTechniquePage.tsx +++ b/app/src/features/surveys/sampling-information/techniques/edit/EditTechniquePage.tsx @@ -123,9 +123,6 @@ export const EditTechniquePage = () => { formattedTechniqueObject ); - // Refresh the context, so the next page loads with the latest data - surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - // Success, navigate back to the manage sampling information page history.push( `/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling`, diff --git a/app/src/features/surveys/sampling-information/techniques/table/SamplingTechniqueTable.tsx b/app/src/features/surveys/sampling-information/techniques/table/SamplingTechniqueTable.tsx index 449e1c6bfa..9422990c7b 100644 --- a/app/src/features/surveys/sampling-information/techniques/table/SamplingTechniqueTable.tsx +++ b/app/src/features/surveys/sampling-information/techniques/table/SamplingTechniqueTable.tsx @@ -15,7 +15,6 @@ import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { LoadingGuard } from 'components/loading/LoadingGuard'; import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { DeleteTechniqueI18N } from 'constants/i18n'; -import { useBiohubApi } from 'hooks/useBioHubApi'; import { useCodesContext, useDialogContext, useSurveyContext } from 'hooks/useContext'; import { IGetTechniqueResponse, TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; import { useEffect, useState } from 'react'; @@ -33,8 +32,9 @@ export interface ITechniqueRowData { interface ISamplingTechniqueTable { techniques: IGetTechniqueResponse[]; - bulkActionTechniques: GridRowSelectionModel; - setBulkActionTechniques: (selection: GridRowSelectionModel) => void; + selectedRows: GridRowSelectionModel; + setSelectedRows: (selection: GridRowSelectionModel) => void; + onDelete: (techniqueId: number) => Promise; } /** @@ -43,16 +43,17 @@ interface ISamplingTechniqueTable { * @returns */ export const SamplingTechniqueTable = (props: ISamplingTechniqueTable) => { - const { techniques, bulkActionTechniques, setBulkActionTechniques } = props; + const { techniques, selectedRows, setSelectedRows, onDelete } = props; // Individual row action menu - const [actionMenuTechnique, setActionMenuTechnique] = useState(null); - const [actionMenuAnchorEl, setActionMenuAnchorEl] = useState(null); + const [actionMenuAnchorEl, setActionMenuAnchorEl] = useState<{ + anchor: MenuProps['anchorEl']; + techniqueId: number; + } | null>(null); const surveyContext = useSurveyContext(); const dialogContext = useDialogContext(); const codesContext = useCodesContext(); - const biohubApi = useBiohubApi(); useEffect(() => { codesContext.codesDataLoader.load(); @@ -63,12 +64,14 @@ export const SamplingTechniqueTable = (props: ISamp * */ const handleDeleteTechnique = async () => { - await biohubApi.technique - .deleteTechnique(surveyContext.projectId, surveyContext.surveyId, Number(actionMenuTechnique)) + if (!actionMenuAnchorEl) { + return; + } + + await onDelete(actionMenuAnchorEl.techniqueId) .then(() => { dialogContext.setYesNoDialog({ open: false }); setActionMenuAnchorEl(null); - surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); }) .catch((error: any) => { dialogContext.setYesNoDialog({ open: false }); @@ -124,7 +127,11 @@ export const SamplingTechniqueTable = (props: ISamp })) || []; const columns: GridColDef[] = [ - { field: 'name', headerName: 'Name', flex: 0.4 }, + { + field: 'name', + headerName: 'Name', + flex: 0.4 + }, { field: 'method_lookup_id', flex: 0.4, @@ -190,19 +197,16 @@ export const SamplingTechniqueTable = (props: ISamp field: 'actions', type: 'actions', sortable: false, - flex: 0.3, + width: 10, align: 'right', renderCell: (params) => { return ( - - { - setActionMenuTechnique(params.row.id); - setActionMenuAnchorEl(event.currentTarget); - }}> - - - + { + setActionMenuAnchorEl({ anchor: event.currentTarget, techniqueId: params.row.id }); + }}> + + ); } } @@ -214,7 +218,7 @@ export const SamplingTechniqueTable = (props: ISamp sx={{ pb: 2 }} open={Boolean(actionMenuAnchorEl)} onClose={() => setActionMenuAnchorEl(null)} - anchorEl={actionMenuAnchorEl} + anchorEl={actionMenuAnchorEl?.anchor} anchorOrigin={{ vertical: 'top', horizontal: 'right' @@ -239,7 +243,7 @@ export const SamplingTechniqueTable = (props: ISamp } }}> + to={`/admin/projects/${surveyContext.projectId}/surveys/${surveyContext.surveyId}/sampling/techniques/${actionMenuAnchorEl?.techniqueId}/edit`}> @@ -272,13 +276,13 @@ export const SamplingTechniqueTable = (props: ISamp 'auto'} + autoHeight={false} disableRowSelectionOnClick disableColumnMenu checkboxSelection - rowSelectionModel={bulkActionTechniques} - onRowSelectionModelChange={setBulkActionTechniques} + rowSelectionModel={selectedRows} + onRowSelectionModelChange={setSelectedRows} initialState={{ pagination: { paginationModel: { page: 1, pageSize: 10 } diff --git a/app/src/features/surveys/telemetry/TelemetryHeader.tsx b/app/src/features/surveys/telemetry/TelemetryHeader.tsx index 85fb278961..9db58ca6a0 100644 --- a/app/src/features/surveys/telemetry/TelemetryHeader.tsx +++ b/app/src/features/surveys/telemetry/TelemetryHeader.tsx @@ -1,9 +1,9 @@ +import { mdiEye, mdiPaw } from '@mdi/js'; import Breadcrumbs from '@mui/material/Breadcrumbs'; import Link from '@mui/material/Link'; -import Typography from '@mui/material/Typography'; +import { BreadcrumbNavButton } from 'components/buttons/BreadcrumbNavButton'; import PageHeader from 'components/layout/PageHeader'; import { Link as RouterLink } from 'react-router-dom'; - export interface TelemetryHeaderProps { project_id: number; project_name: string; @@ -13,6 +13,20 @@ export interface TelemetryHeaderProps { export const TelemetryHeader = (props: TelemetryHeaderProps) => { const { project_id, project_name, survey_id, survey_name } = props; + + const menuItems = [ + { + label: 'Animals', + to: `/admin/projects/${project_id}/surveys/${survey_id}/animals`, + icon: mdiPaw + }, + { + label: 'Observations', + to: `/admin/projects/${project_id}/surveys/${survey_id}/observations`, + icon: mdiEye + } + ]; + return ( { to={`/admin/projects/${project_id}/surveys/${survey_id}/details`}> {survey_name} - - Manage Telemetry - + Telemetry } /> diff --git a/app/src/features/surveys/telemetry/TelemetryPage.tsx b/app/src/features/surveys/telemetry/TelemetryPage.tsx index c91b025ff6..f969f3f3f6 100644 --- a/app/src/features/surveys/telemetry/TelemetryPage.tsx +++ b/app/src/features/surveys/telemetry/TelemetryPage.tsx @@ -2,12 +2,14 @@ import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import Stack from '@mui/material/Stack'; import { TelemetryTableContextProvider } from 'contexts/telemetryTableContext'; +import { SystemAlertBanner } from 'features/alert/banner/SystemAlertBanner'; import { SurveyDeploymentList } from 'features/surveys/telemetry/list/SurveyDeploymentList'; import { TelemetryTableContainer } from 'features/surveys/telemetry/table/TelemetryTableContainer'; import { TelemetryHeader } from 'features/surveys/telemetry/TelemetryHeader'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useProjectContext, useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; +import { SystemAlertBannerEnum } from 'interfaces/useAlertApi.interface'; import { useEffect } from 'react'; export const TelemetryPage = () => { @@ -72,6 +74,7 @@ export const TelemetryPage = () => { survey_id={surveyContext.surveyId} survey_name={surveyContext.surveyDataLoader.data.surveyData.survey_details.survey_name} /> + {/* Telematry List */} diff --git a/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx index df3b446537..667ec1691d 100644 --- a/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx +++ b/app/src/features/surveys/telemetry/deployments/components/form/timeline/DeploymentTimelineForm.tsx @@ -243,14 +243,12 @@ export const DeploymentTimelineForm = (props: IDeploymentTimelineFormProps) => { name="attachment_end_date" label="End date" required={values.attachment_end_time !== null} - formikProps={formikProps} /> )} diff --git a/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysList.tsx b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysList.tsx index 4acb190112..70f812aae1 100644 --- a/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysList.tsx +++ b/app/src/features/surveys/telemetry/device-keys/TelemetryDeviceKeysList.tsx @@ -65,8 +65,8 @@ export const TelemetryDeviceKeysList = (props: ITelemetryDeviceKeysListProps) => { field: 'survey_telemetry_credential_attachment_id', headerName: 'ID', - width: 70, - minWidth: 70, + width: 85, + minWidth: 85, renderHeader: () => ( ID diff --git a/app/src/features/surveys/view/SurveyAttachments.tsx b/app/src/features/surveys/view/SurveyAttachments.tsx index 03c78686fa..df907ac120 100644 --- a/app/src/features/surveys/view/SurveyAttachments.tsx +++ b/app/src/features/surveys/view/SurveyAttachments.tsx @@ -1,6 +1,5 @@ import { mdiAttachment, mdiFilePdfBox, mdiTrayArrowUp } from '@mdi/js'; import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; import { IReportMetaForm } from 'components/attachments/ReportMetaForm'; @@ -79,40 +78,36 @@ const SurveyAttachments = () => { }} /> - - } - menuItems={[ - { - menuLabel: 'Upload a Report', - menuIcon: , - menuOnClick: () => setOpenUploadDialog('Report') - }, - { - menuLabel: 'Upload Attachments', - menuIcon: , - menuOnClick: () => setOpenUploadDialog('Attachment') - } - ]} - renderButton={(buttonProps) => ( - - - + + + + + + diff --git a/app/src/features/surveys/view/SurveyHeader.test.tsx b/app/src/features/surveys/view/SurveyHeader.test.tsx index a1c685afab..3cc6e4c083 100644 --- a/app/src/features/surveys/view/SurveyHeader.test.tsx +++ b/app/src/features/surveys/view/SurveyHeader.test.tsx @@ -21,6 +21,13 @@ import { cleanup, fireEvent, render, waitFor } from 'test-helpers/test-utils'; const history = createMemoryHistory({ initialEntries: ['/admin/projects/1/surveys/2'] }); jest.mock('../../../hooks/useBioHubApi'); + +jest.mock('../../../components/markdown/CustomMarkdown', () => { + // Overriding this component because it is ESM only and Jest does not support ESM. + // See https://github.com/orgs/remarkjs/discussions/1247 for more information. + return {}; +}); + const mockBiohubApi = useBiohubApi as jest.Mock; const mockUseApi = { @@ -44,15 +51,9 @@ const mockSurveyContext: ISurveyContext = { artifactDataLoader: { data: null } as DataLoader, - sampleSiteDataLoader: { - data: null - } as DataLoader, critterDataLoader: { data: null } as DataLoader, - techniqueDataLoader: { - data: [] - } as DataLoader, surveyId: 1, projectId: 1 }; diff --git a/app/src/features/surveys/view/SurveyHeader.tsx b/app/src/features/surveys/view/SurveyHeader.tsx index be6af77b5e..dc12febba6 100644 --- a/app/src/features/surveys/view/SurveyHeader.tsx +++ b/app/src/features/surveys/view/SurveyHeader.tsx @@ -18,6 +18,7 @@ import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import PageHeader from 'components/layout/PageHeader'; import PublishSurveyIdDialog from 'components/publish/PublishSurveyDialog'; @@ -31,6 +32,7 @@ import { SurveyContext } from 'contexts/surveyContext'; import { SurveyExportDialog } from 'features/surveys/view/survey-export/SurveyExportDialog'; import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import React, { useContext, useState } from 'react'; import { useHistory } from 'react-router'; import { Link as RouterLink } from 'react-router-dom'; @@ -169,6 +171,7 @@ const SurveyHeader = () => { validProjectPermissions={[PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR]} validSystemRoles={[SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR]}> + { <> + - + diff --git a/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx b/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx new file mode 100644 index 0000000000..827ac259fa --- /dev/null +++ b/app/src/features/surveys/view/SurveySampleSiteMapPopup.tsx @@ -0,0 +1,42 @@ +import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; + +interface ISurveySampleSiteMapPopupProps { + surveySampleSiteId: number; +} + +export const SurveySampleSiteMapPopup = (props: ISurveySampleSiteMapPopupProps) => { + const { surveySampleSiteId } = props; + const { surveyId, projectId } = useSurveyContext(); + + const biohubApi = useBiohubApi(); + + const surveyDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSiteById(projectId, surveyId, surveySampleSiteId) + ); + + useEffect(() => { + surveyDataLoader.load(); + }, [surveyDataLoader]); + + const sampleSite = surveyDataLoader.data; + + const metadata = sampleSite + ? [ + { label: 'Name', value: sampleSite.name }, + { label: 'Description', value: sampleSite.description } + ] + : []; + + return ( + + ); +}; diff --git a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx index 1bb03a26b5..8f347de796 100644 --- a/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx +++ b/app/src/features/surveys/view/components/SurveyGeneralInformation.test.tsx @@ -22,8 +22,6 @@ describe('SurveyGeneralInformation', () => { it('renders correctly with end date', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -34,8 +32,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -60,8 +56,6 @@ describe('SurveyGeneralInformation', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -72,9 +66,7 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -87,8 +79,6 @@ describe('SurveyGeneralInformation', () => { it('renders an empty fragment if survey data has not loaded or is undefined', () => { const mockSurveyDataLoader = { data: undefined } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; const { container } = render( @@ -99,8 +89,6 @@ describe('SurveyGeneralInformation', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx index ef52862732..964e81761f 100644 --- a/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyProprietaryData.test.tsx @@ -13,9 +13,7 @@ describe('SurveyProprietaryData', () => { it('renders correctly with proprietor data', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -42,9 +38,7 @@ describe('SurveyProprietaryData', () => { data: { ...getSurveyForViewResponse, surveyData: { ...getSurveyForViewResponse.surveyData, proprietor: null } } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -69,9 +61,7 @@ describe('SurveyProprietaryData', () => { it('renders an empty fragment if survey data has not loaded or is undefined', () => { const mockSurveyDataLoader = { data: undefined } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container } = render( { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader + critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx index 3390775e55..0753060859 100644 --- a/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx +++ b/app/src/features/surveys/view/components/SurveyPurposeAndMethodologyData.test.tsx @@ -21,9 +21,7 @@ describe('SurveyPurposeAndMethodologyData', () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId } = render( @@ -33,9 +31,7 @@ describe('SurveyPurposeAndMethodologyData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -66,9 +62,7 @@ describe('SurveyPurposeAndMethodologyData', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { getByTestId, queryByTestId } = render( @@ -78,9 +72,7 @@ describe('SurveyPurposeAndMethodologyData', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader + critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx index 47e7ca53e1..fe83a5f218 100644 --- a/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx +++ b/app/src/features/surveys/view/components/SurveyStudyArea.test.tsx @@ -27,7 +27,6 @@ describe.skip('SurveyStudyArea', () => { mockUseApi.survey.getSurveyForView.mockClear(); mockUseApi.survey.updateSurvey.mockClear(); mockUseApi.spatial.getRegions.mockClear(); - mockUseApi.spatial.getRegions.mockResolvedValue({ regions: [] }); @@ -42,9 +41,7 @@ describe.skip('SurveyStudyArea', () => { it('renders correctly with no data', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container } = render( { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, + critterDataLoader: mockCritterDataLoader }}> @@ -78,9 +74,7 @@ describe.skip('SurveyStudyArea', () => { } } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container, queryByTestId } = render( { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - techniqueDataLoader: mockTechniqueDataLoader, critterDataLoader: mockCritterDataLoader }}> @@ -106,9 +98,7 @@ describe.skip('SurveyStudyArea', () => { it('is rendered if there are geometries on the map', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; const { container, getByTestId } = render( { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -138,10 +126,7 @@ describe.skip('SurveyStudyArea', () => { refresh: jest.fn() as unknown as any } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; - const mockProjectAuthStateContext: IProjectAuthStateContext = { getProjectParticipant: () => null, hasProjectRole: () => true, @@ -159,9 +144,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader + critterDataLoader: mockCritterDataLoader }}> @@ -230,9 +213,7 @@ describe.skip('SurveyStudyArea', () => { it('shows error dialog with API error message when updating survey data fails', async () => { const mockSurveyDataLoader = { data: getSurveyForViewResponse } as DataLoader; const mockArtifactDataLoader = { data: null } as DataLoader; - const mockSampleSiteDataLoader = { data: null } as DataLoader; const mockCritterDataLoader = { data: [] } as DataLoader; - const mockTechniqueDataLoader = { data: [] } as DataLoader; mockUseApi.survey.getSurveyForView.mockResolvedValue({ surveyData: { @@ -269,9 +250,7 @@ describe.skip('SurveyStudyArea', () => { surveyId: 1, surveyDataLoader: mockSurveyDataLoader, artifactDataLoader: mockArtifactDataLoader, - sampleSiteDataLoader: mockSampleSiteDataLoader, - critterDataLoader: mockCritterDataLoader, - techniqueDataLoader: mockTechniqueDataLoader + critterDataLoader: mockCritterDataLoader }}> diff --git a/app/src/features/surveys/view/components/analytics/SurveyObservationAnalytics.tsx b/app/src/features/surveys/view/components/analytics/SurveyObservationAnalytics.tsx index a12191b6b1..7ab526e25c 100644 --- a/app/src/features/surveys/view/components/analytics/SurveyObservationAnalytics.tsx +++ b/app/src/features/surveys/view/components/analytics/SurveyObservationAnalytics.tsx @@ -15,7 +15,7 @@ import { useSurveyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import startCase from 'lodash-es/startCase'; import { useEffect, useState } from 'react'; -import { ObservationAnalyticsNoDataOverlay } from './components/ObservationAnalyticsNoDataOverlay'; + type GroupByColumnType = 'column' | 'quantitative_measurement' | 'qualitative_measurement'; export type IGroupByOption = { @@ -89,8 +89,6 @@ export const SurveyObservationAnalytics = () => { : [...groupBy, value] ); - const allGroupByColumns = [...groupByColumns, ...groupByQualitativeMeasurements, ...groupByQuantitativeMeasurements]; - return ( { orientation="vertical" onChange={handleToggleChange} sx={{ - width: '100%', - '& .MuiToggleButton-root': { + display: 'flex', + flex: '1 1 auto', + gap: 0.5, + '& Button': { + py: 1, + px: 2, border: 'none', - outline: 'none', borderRadius: '4px !important', fontSize: '0.875rem', + fontWeight: 700, letterSpacing: '0.02rem', - textTransform: 'none', - '&::first-letter': { - textTransform: 'capitalize !important' - } + justifyContent: 'flex-start' } }}> {/* Render toggle buttons for each group by option */} @@ -139,18 +138,6 @@ export const SurveyObservationAnalytics = () => { component={Button} color="primary" value={option} - sx={{ - textAlign: 'left', - display: 'block', - border: 'none', - outline: 'none', - fontWeight: 700, - my: 1, - ':focus': { - outline: 'none', - border: 'none' - } - }} selected={ groupByColumns.some((item) => item.field === option.field) || groupByQualitativeMeasurements.some((item) => item.field === option.field) || @@ -158,7 +145,7 @@ export const SurveyObservationAnalytics = () => { }> item.field === option.field) || groupByQualitativeMeasurements.some((item) => item.field === option.field) || @@ -175,19 +162,12 @@ export const SurveyObservationAnalytics = () => { - {/* Overlay for when no group by columns are selected */} - {allGroupByColumns.length === 0 && !measurementDefinitionsDataLoader.isLoading && ( - - )} - {/* Data grid displaying fetched data */} - {measurementDefinitionsDataLoader.data && allGroupByColumns.length > 0 && ( - - )} + ); diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx index b5e09248f1..e879240333 100644 --- a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx +++ b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer.tsx @@ -1,10 +1,16 @@ +import { mdiArrowTopRight } from '@mdi/js'; +import Box from '@mui/material/Box'; import { GridColumnVisibilityModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; import { ObservationAnalyticsDataTable } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTable'; import { IGroupByOption } from 'features/surveys/view/components/analytics/SurveyObservationAnalytics'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext, useTaxonomyContext } from 'hooks/useContext'; import useDataLoader from 'hooks/useDataLoader'; import { IObservationCountByGroup } from 'interfaces/useAnalyticsApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { useEffect, useMemo } from 'react'; import { getBasicGroupByColDefs, @@ -96,10 +102,9 @@ export const ObservationAnalyticsDataTableContainer = (props: IObservationAnalyt [analyticsDataLoader?.data] ); - const sampleSites = useMemo( - () => surveyContext.sampleSiteDataLoader.data?.sampleSites ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); + // TODO: Include sampling information in the analytics response / otherwise get sampling information, + // which is now more complicated because sample sites are paginated. + const sampleSites: IGetSampleLocationNonSpatialDetails[] = []; const allGroupByColumns = useMemo( () => [...groupByColumns, ...groupByQualitativeMeasurements, ...groupByQuantitativeMeasurements], @@ -144,11 +149,30 @@ export const ObservationAnalyticsDataTableContainer = (props: IObservationAnalyt }, [allGroupByColumns, columns]); return ( - + + + } + isLoadingFallbackDelay={100} + hasNoData={!analyticsDataLoader.data?.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + ); }; diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsNoDataOverlay.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsNoDataOverlay.tsx deleted file mode 100644 index f56d03e844..0000000000 --- a/app/src/features/surveys/view/components/analytics/components/ObservationAnalyticsNoDataOverlay.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import Box from '@mui/material/Box'; -import grey from '@mui/material/colors/grey'; -import Stack from '@mui/material/Stack'; -import Typography from '@mui/material/Typography'; - -/** - * Returns an overlay with instructions on how to use the analytics feature. - * - * @return {*} - */ -export const ObservationAnalyticsNoDataOverlay = () => { - return ( - - - Calculate sex ratios, demographics, and more - - - Choose fields to analyze - - - The group by options depend on which fields apply to your observations. To add options, such as life stage - and sex, add fields to your observations by configuring your observations table. - - - - - How the calculations work  - - - The number observations and individuals will be calculated for each group. For example, if you group by life - stage, the number of individuals belonging to each life stage category will be calculated. If you group by - multiple fields, such as life stage and sex, the number of individuals belonging to each life stage and sex - combination will be calculated. - - - - - ); -}; diff --git a/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx index b24ef04e98..38acf9eb17 100644 --- a/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx +++ b/app/src/features/surveys/view/components/analytics/components/ObservationsAnalyticsGridColumnDefinitions.tsx @@ -6,7 +6,7 @@ import dayjs from 'dayjs'; import { ScientificNameTypography } from 'features/surveys/animals/components/ScientificNameTypography'; import { IObservationAnalyticsRow } from 'features/surveys/view/components/analytics/components/ObservationAnalyticsDataTableContainer'; import { IGroupByOption } from 'features/surveys/view/components/analytics/SurveyObservationAnalytics'; -import { IGetSampleLocationDetails } from 'interfaces/useSamplingSiteApi.interface'; +import { IGetSampleLocationNonSpatialDetails } from 'interfaces/useSamplingSiteApi.interface'; import { IPartialTaxonomy } from 'interfaces/useTaxonomyApi.interface'; import isEqual from 'lodash-es/isEqual'; @@ -19,10 +19,10 @@ export const getRowCountColDef = (): GridColDef => ({ headerAlign: 'left', align: 'left', field: 'row_count', - headerName: 'Number of observations', + headerName: 'Count of observations', type: 'number', flex: 1, - minWidth: 180 + minWidth: 150 }); /** @@ -34,10 +34,10 @@ export const getIndividualCountColDef = (): GridColDef headerAlign: 'left', align: 'left', field: 'individual_count', - headerName: 'Number of individuals', + headerName: 'Count of individuals', type: 'number', flex: 1, - minWidth: 180 + minWidth: 150 }); /** @@ -52,7 +52,7 @@ export const getIndividualPercentageColDef = (): GridColDef ( {params.row.individual_percentage}  @@ -76,7 +76,8 @@ export const getSpeciesColDef = ( align: 'left', field: 'itis_tsn', headerName: 'Species', - minWidth: 180, + flex: 1, + minWidth: 150, renderCell: (params) => { if (!params.row.itis_tsn) { return null; @@ -91,17 +92,18 @@ export const getSpeciesColDef = ( /** * Get the column definition for the sampling site. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingSiteColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', field: 'survey_sample_site_id', headerName: 'Site', - minWidth: 180, + flex: 1, + minWidth: 150, renderCell: (params) => { if (!params.row.survey_sample_site_id) { return null; @@ -120,17 +122,18 @@ export const getSamplingSiteColDef = ( /** * Get the column definition for the sampling method. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingMethodColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', field: 'survey_sample_method_id', headerName: 'Method', - minWidth: 180, + flex: 1, + minWidth: 150, renderCell: (params) => { if (!params.row.survey_sample_method_id) { return null; @@ -151,17 +154,18 @@ export const getSamplingMethodColDef = ( /** * Get the column definition for the sampling period. * - * @param {IGetSampleLocationDetails[]} sampleSites + * @param {IGetSampleLocationNonSpatialDetails[]} sampleSites * @return {*} {GridColDef} */ export const getSamplingPeriodColDef = ( - sampleSites: IGetSampleLocationDetails[] + sampleSites: IGetSampleLocationNonSpatialDetails[] ): GridColDef => ({ headerAlign: 'left', align: 'left', field: 'survey_sample_period_id', headerName: 'Period', - minWidth: 220, + flex: 1, + minWidth: 180, renderCell: (params) => { if (!params.row.survey_sample_period_id) { return null; @@ -176,10 +180,13 @@ export const getSamplingPeriodColDef = ( return null; } + const formattedDateRange = `${dayjs(period.start_date).format(DATE_FORMAT.ShortMediumDateFormat)} – ${dayjs( + period.end_date + ).format(DATE_FORMAT.ShortMediumDateFormat)}`; + return ( - - {dayjs(period.start_date).format(DATE_FORMAT.ShortMediumDateFormat)}– - {dayjs(period.end_date).format(DATE_FORMAT.ShortMediumDateFormat)} + + {formattedDateRange} ); } @@ -195,7 +202,8 @@ export const getDateColDef = (): GridColDef => ({ align: 'left', field: 'observation_date', headerName: 'Date', - minWidth: 180, + minWidth: 150, + flex: 1, renderCell: (params) => params.row.observation_date ? ( {dayjs(params.row.observation_date).format(DATE_FORMAT.MediumDateFormat)} @@ -216,6 +224,7 @@ export const getBasicGroupByColDefs = (groupByOptions: IGroupByOption[]): GridCo return groupByOptions.map((item) => ({ field: item.field, headerName: item.label, - minWidth: 180 + minWidth: 150, + flex: 1 })); }; diff --git a/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx index 34ed569e19..cd5f80950e 100644 --- a/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx +++ b/app/src/features/surveys/view/components/data-container/SurveyObservationTabularDataContainer.tsx @@ -1,10 +1,7 @@ import { mdiChartBar, mdiTallyMark5 } from '@mdi/js'; -import Icon from '@mdi/react'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; import Divider from '@mui/material/Divider'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; import { useState } from 'react'; import { SurveySpatialObservationTable } from '../../survey-spatial/components/observation/SurveySpatialObservationTable'; import { SurveyObservationAnalytics } from '../analytics/SurveyObservationAnalytics'; @@ -14,14 +11,8 @@ export enum SurveyObservationTabularDataContainerViewEnum { ANALYTICS = 'ANALYTICS' } -interface ISurveyObservationTabularDataContainerProps { - isLoading: boolean; -} - -const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularDataContainerProps) => { - const { isLoading } = props; - - const [activeDataView, setActiveDataView] = useState( +const SurveyObservationTabularDataContainer = () => { + const [activeView, setActiveView] = useState( SurveyObservationTabularDataContainerViewEnum.COUNTS ); @@ -32,49 +23,18 @@ const SurveyObservationTabularDataContainer = (props: ISurveyObservationTabularD return ( <> - - { - if (!view) { - // An active view must be selected at all times - return; - } - - setActiveDataView(view); - }} - exclusive - sx={{ - display: 'flex', - gap: 1, - '& Button': { - py: 0.25, - px: 1.5, - border: 'none', - borderRadius: '4px !important', - fontSize: '0.875rem', - fontWeight: 700, - letterSpacing: '0.02rem' - } - }}> - {views.map((view) => ( - } - value={view.value}> - {view.label} - - ))} - + + setActiveView(view)} + orientation="horizontal" + /> - - {activeDataView === SurveyObservationTabularDataContainerViewEnum.COUNTS && ( - - )} - {activeDataView === SurveyObservationTabularDataContainerViewEnum.ANALYTICS && } + + {activeView === SurveyObservationTabularDataContainerViewEnum.COUNTS && } + {activeView === SurveyObservationTabularDataContainerViewEnum.ANALYTICS && } ); diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx deleted file mode 100644 index 7d37ea1279..0000000000 --- a/app/src/features/surveys/view/components/sampling-data/SurveySamplingContainer.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import Box from '@mui/material/Box'; -import Divider from '@mui/material/Divider'; -import { SurveySamplingTabs } from 'features/surveys/view/components/sampling-data/components/SurveySamplingTabs'; -import { SurveySamplingHeader } from './components/SurveySamplingHeader'; - -export const SurveySamplingContainer = () => { - return ( - - - - - - - - ); -}; diff --git a/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx b/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx new file mode 100644 index 0000000000..5dcbb61316 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/SurveySamplingTableContainer.tsx @@ -0,0 +1,280 @@ +import { mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import { LoadingGuard } from 'components/loading/LoadingGuard'; +import { SkeletonTable } from 'components/loading/SkeletonLoaders'; +import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; +import CustomToggleButtonGroup from 'components/toolbar/CustomToggleButtonGroup'; +import { SurveyPeriodsTable } from 'features/surveys/view/components/sampling-data/components/period/SurveyPeriodsTable'; +import { SurveyTechniquesCardContainer } from 'features/surveys/view/components/sampling-data/components/technique/SurveyTechniqueCardContainer'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiPaginationRequestOptions } from 'types/misc'; +import { firstOrNull } from 'utils/Utils'; +import { SurveySitesTable } from './components/site/SurveySitesTable'; +import { SurveySamplingHeader } from './components/SurveySamplingHeader'; + +const pageSizeOptions = [10, 25, 50]; + +export enum SurveySamplingView { + TECHNIQUES = 'TECHNIQUES', + SITES = 'SITES', + PERIODS = 'PERIODS' +} + +export const SurveySamplingTableContainer = () => { + const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + // Views + const [activeView, setActiveView] = useState(SurveySamplingView.TECHNIQUES); + + const views = [ + { value: SurveySamplingView.TECHNIQUES, label: 'Techniques', icon: mdiAutoFix }, + { value: SurveySamplingView.SITES, label: 'Sampling Sites', icon: mdiMapMarker }, + { value: SurveySamplingView.PERIODS, label: 'Sampling Periods', icon: mdiCalendarRange } + ]; + + // Techniques + const [techniquesPaginationModel, setTechniquesPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + + const [techniquesSortModel, setTechniquesSortModel] = useState([]); + + const techniquesPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(techniquesSortModel); + return { + limit: techniquesPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: techniquesPaginationModel.page + 1 + }; + }, [techniquesSortModel, techniquesPaginationModel]); + + const techniquesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.technique.getTechniquesForSurvey(surveyContext.projectId, surveyContext.surveyId, pagination) + ); + + // Sites + const [sitesPaginationModel, setSitesPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + + const [sitesSortModel, setSitesSortModel] = useState([]); + + const sitesPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(sitesSortModel); + return { + limit: sitesPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: sitesPaginationModel.page + 1 + }; + }, [sitesSortModel, sitesPaginationModel]); + + const samplingSitesDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.findSampleSites({ survey_id: surveyContext.surveyId }, pagination) + ); + + // Periods + const [periodsPaginationModel, setPeriodsPaginationModel] = useState({ + page: 0, + pageSize: pageSizeOptions[0] + }); + + const [periodsSortModel, setPeriodsSortModel] = useState([]); + + const periodsPagination: ApiPaginationRequestOptions = useMemo(() => { + const sort = firstOrNull(periodsSortModel); + return { + limit: periodsPaginationModel.pageSize, + sort: sort?.field || undefined, + order: sort?.sort || undefined, + page: periodsPaginationModel.page + 1 + }; + }, [periodsSortModel, periodsPaginationModel]); + + const samplingPeriodsDataLoader = useDataLoader((pagination: ApiPaginationRequestOptions) => + biohubApi.samplingSite.findSamplePeriods({ survey_id: surveyContext.surveyId }, pagination) + ); + + useEffect(() => { + // Refresh active view data loader when switching to the view for the first time + if (activeView === SurveySamplingView.TECHNIQUES && !techniquesDataLoader.data) { + techniquesDataLoader.load(techniquesPagination); + } + + if (activeView === SurveySamplingView.SITES && !samplingSitesDataLoader.data) { + samplingSitesDataLoader.refresh(sitesPagination); + } + + if (activeView === SurveySamplingView.PERIODS && !samplingPeriodsDataLoader.data) { + samplingPeriodsDataLoader.refresh(periodsPagination); + } + // Including data loaders in the dependency array causes infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeView]); + + useEffect(() => { + if (activeView === SurveySamplingView.TECHNIQUES && Number(techniquesDataLoader.data?.pagination.total) !== 0) { + techniquesDataLoader.refresh(techniquesPagination); + } + // Including data loaders in the dependency array causes infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [techniquesPagination]); + + useEffect(() => { + if (activeView === SurveySamplingView.SITES && Number(samplingSitesDataLoader.data?.pagination.total) !== 0) { + samplingSitesDataLoader.refresh(sitesPagination); + } + // Including data loaders in the dependency array causes infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sitesPagination]); + + useEffect(() => { + if (activeView === SurveySamplingView.PERIODS && Number(samplingPeriodsDataLoader.data?.pagination.total) !== 0) { + samplingPeriodsDataLoader.refresh(periodsPagination); + } + // Including data loaders in the dependency array causes infinite reloads + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [periodsPagination]); + + // Data + const techniques = techniquesDataLoader.data?.techniques ?? []; + const sampleSites = samplingSitesDataLoader.data?.sites ?? []; + const samplePeriods = samplingPeriodsDataLoader.data?.periods ?? []; + + return ( + + + + + + + + + + + + + + {activeView === SurveySamplingView.TECHNIQUES && ( + + + + } + isLoadingFallbackDelay={100} + hasNoData={!techniques.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + + )} + + {activeView === SurveySamplingView.SITES && ( + + + + } + isLoadingFallbackDelay={100} + hasNoData={!sampleSites.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + + )} + + {/* TODO: Add pagination to the survey periods request */} + {activeView === SurveySamplingView.PERIODS && ( + + + + } + isLoadingFallbackDelay={100} + hasNoData={!samplePeriods.length} + hasNoDataFallback={ + + } + hasNoDataFallbackDelay={100}> + + + + + )} + + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingHeader.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingHeader.tsx index c9a3d0f698..050fb2748d 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingHeader.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingHeader.tsx @@ -1,10 +1,13 @@ import { mdiCog } from '@mdi/js'; import Icon from '@mdi/react'; import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; import { ProjectRoleGuard } from 'components/security/Guards'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { Link as RouterLink } from 'react-router-dom'; export const SurveySamplingHeader = () => { @@ -13,22 +16,22 @@ export const SurveySamplingHeader = () => { Sampling Information - - - + + + + + + ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx deleted file mode 100644 index c290494e35..0000000000 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySamplingTabs.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { mdiArrowTopRight, mdiAutoFix, mdiCalendarRange, mdiMapMarker } from '@mdi/js'; -import Icon from '@mdi/react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Divider from '@mui/material/Divider'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; -import { LoadingGuard } from 'components/loading/LoadingGuard'; -import { SkeletonTable } from 'components/loading/SkeletonLoaders'; -import { NoDataOverlay } from 'components/overlay/NoDataOverlay'; -import { - ISamplingSitePeriodRowData, - SamplingPeriodTable -} from 'features/surveys/sampling-information/periods/table/SamplingPeriodTable'; -import { - ISurveySitesRowData, - SurveySitesTable -} from 'features/surveys/view/components/sampling-data/components/SurveySitesTable'; -import { - ISurveyTechniqueRowData, - SurveyTechniquesTable -} from 'features/surveys/view/components/sampling-data/components/SurveyTechniquesTable'; -import { useSurveyContext } from 'hooks/useContext'; -import { useEffect, useMemo, useState } from 'react'; - -export enum SurveySamplingView { - TECHNIQUES = 'TECHNIQUES', - SITES = 'SITES', - PERIODS = 'PERIODS' -} - -export const SurveySamplingTabs = () => { - const surveyContext = useSurveyContext(); - - const [activeView, setActiveView] = useState(SurveySamplingView.TECHNIQUES); - - useEffect(() => { - // Refresh the data for the active view if the project or survey ID changes - if (activeView === SurveySamplingView.TECHNIQUES) { - surveyContext.techniqueDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - } - if (activeView === SurveySamplingView.SITES) { - surveyContext.sampleSiteDataLoader.refresh(surveyContext.projectId, surveyContext.surveyId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeView]); - - useEffect(() => { - // Load the data initially once per tab, if/when the active view changes - if (activeView === SurveySamplingView.TECHNIQUES) { - surveyContext.techniqueDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - } - if (activeView === SurveySamplingView.SITES) { - surveyContext.sampleSiteDataLoader.load(surveyContext.projectId, surveyContext.surveyId); - } - }, [ - activeView, - surveyContext.techniqueDataLoader, - surveyContext.sampleSiteDataLoader, - surveyContext.projectId, - surveyContext.surveyId - ]); - - const techniques: ISurveyTechniqueRowData[] = - surveyContext.techniqueDataLoader.data?.techniques.map((technique) => ({ - id: technique.method_technique_id, - name: technique.name, - method_lookup_id: technique.method_lookup_id, - description: technique.description, - attractants: technique.attractants, - distance_threshold: technique.distance_threshold - })) ?? []; - - const sampleSites: ISurveySitesRowData[] = useMemo( - () => - surveyContext.sampleSiteDataLoader.data?.sampleSites.map((site) => ({ - id: site.survey_sample_site_id, - name: site.name, - description: site.description, - geojson: site.geojson, - blocks: site.blocks.map((block) => block.name), - stratums: site.stratums.map((stratum) => stratum.name) - })) ?? [], - [surveyContext.sampleSiteDataLoader.data?.sampleSites] - ); - - const samplePeriods: ISamplingSitePeriodRowData[] = useMemo(() => { - const data: ISamplingSitePeriodRowData[] = []; - - for (const site of surveyContext.sampleSiteDataLoader.data?.sampleSites ?? []) { - for (const method of site.sample_methods) { - for (const period of method.sample_periods) { - data.push({ - id: period.survey_sample_period_id, - sample_site: site.name, - sample_method: method.technique.name, - method_response_metric_id: method.method_response_metric_id, - start_date: period.start_date, - end_date: period.end_date, - start_time: period.start_time, - end_time: period.end_time - }); - } - } - } - - return data; - }, [surveyContext.sampleSiteDataLoader.data?.sampleSites]); - - const techniquesCount = surveyContext.techniqueDataLoader.data?.count; - const sampleSitesCount = surveyContext.sampleSiteDataLoader.data?.sampleSites.length; - const samplePeriodsCount = samplePeriods.length; - - return ( - <> - - { - if (!view) { - // An active view must be selected at all times - return; - } - - setActiveView(view); - }} - exclusive - sx={{ - display: 'flex', - gap: 1, - '& Button': { - py: 0.25, - px: 1.5, - border: 'none', - borderRadius: '4px !important', - fontSize: '0.875rem', - fontWeight: 700, - letterSpacing: '0.02rem' - } - }}> - } - value={SurveySamplingView.TECHNIQUES}> - {`${SurveySamplingView.TECHNIQUES} (${techniquesCount ?? 0})`} - - } - value={SurveySamplingView.SITES}> - {`${SurveySamplingView.SITES} (${sampleSitesCount ?? 0})`} - - } - value={SurveySamplingView.PERIODS}> - {`${SurveySamplingView.PERIODS} (${samplePeriodsCount ?? 0})`} - - - - - - - - {activeView === SurveySamplingView.TECHNIQUES && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!techniquesCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - {activeView === SurveySamplingView.SITES && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!sampleSitesCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - {activeView === SurveySamplingView.PERIODS && ( - <> - } - isLoadingFallbackDelay={100} - hasNoData={!samplePeriodsCount} - hasNoDataFallback={ - - } - hasNoDataFallbackDelay={100}> - - - - )} - - - ); -}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx deleted file mode 100644 index 3aa338f693..0000000000 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveyTechniquesTable.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import Box from '@mui/material/Box'; -import blueGrey from '@mui/material/colors/blueGrey'; -import Typography from '@mui/material/Typography'; -import { GridColDef } from '@mui/x-data-grid'; -import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; -import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; -import { ITechniqueRowData } from 'features/surveys/sampling-information/techniques/table/SamplingTechniqueTable'; -import { useCodesContext } from 'hooks/useContext'; -import { TechniqueAttractant } from 'interfaces/useTechniqueApi.interface'; -import { getCodesName } from 'utils/Utils'; - -export interface ISurveyTechniqueRowData { - id: number; - method_lookup_id: number; - name: string; - description: string | null; - attractants: TechniqueAttractant[]; - distance_threshold: number | null; -} - -export interface ISurveyTechniquesTableProps { - techniques: ISurveyTechniqueRowData[]; -} - -export const SurveyTechniquesTable = (props: ISurveyTechniquesTableProps) => { - const { techniques } = props; - - const codesContext = useCodesContext(); - - const columns: GridColDef[] = [ - { - field: 'name', - headerName: 'Name', - flex: 1 - }, - { - field: 'method_lookup_id', - flex: 1, - headerName: 'Method', - renderCell: (params) => ( - - ) - }, - { - field: 'description', - headerName: 'Description', - flex: 1, - renderCell: (params) => { - return ( - - - {params.row.description} - - - ); - } - }, - { - field: 'attractants', - flex: 1, - headerName: 'Attractants', - renderCell: (params) => ( - - {params.row.attractants.map((attractant) => ( - - - - ))} - - ) - }, - { - field: 'distance_threshold', - headerName: 'Distance threshold', - flex: 1, - renderCell: (params) => (params.row.distance_threshold ? <>{params.row.distance_threshold} m : <>) - } - ]; - - return ( - 'auto'} - rows={techniques} - getRowId={(row) => row.id} - columns={columns} - disableRowSelectionOnClick - initialState={{ - pagination: { - paginationModel: { page: 1, pageSize: 10 } - } - }} - pageSizeOptions={[10, 25, 50]} - /> - ); -}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/period/SurveyPeriodsTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/period/SurveyPeriodsTable.tsx new file mode 100644 index 0000000000..784395d8f1 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/period/SurveyPeriodsTable.tsx @@ -0,0 +1,135 @@ +import Typography from '@mui/material/Typography'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; +import { DATE_FORMAT } from 'constants/dateTimeFormats'; +import dayjs from 'dayjs'; +import { useCodesContext } from 'hooks/useContext'; +import { IFindSamplePeriodRecord } from 'interfaces/useSamplingSiteApi.interface'; +import { useEffect } from 'react'; +import { formatTimeDifference } from 'utils/datetime'; +import { getCodesName } from 'utils/Utils'; + +interface ISamplingPeriodTableProps { + periods: IFindSamplePeriodRecord[]; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + sortModel: GridSortModel; + setSortModel: React.Dispatch>; + rowCount: number; +} + +/** + * Renders a table of survey sampling periods. + * + * @param props {} + * @returns {*} + */ +export const SurveyPeriodsTable = (props: ISamplingPeriodTableProps) => { + const { periods, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; + + const codesContext = useCodesContext(); + + useEffect(() => { + codesContext.codesDataLoader.load(); + }, [codesContext.codesDataLoader]); + + const columns: GridColDef[] = [ + { + field: 'sample_site', + headerName: 'Site', + flex: 1, + valueGetter: (params) => { + return params.row.sample_site.name; + } + }, + { + field: 'sample_method', + headerName: 'Technique', + flex: 1, + valueGetter: (params) => { + return params.row.method_technique.name; + } + }, + { + field: 'method_response_metric_id', + headerName: 'Response Metric', + flex: 1, + valueGetter: (params) => { + const value = getCodesName( + codesContext.codesDataLoader.data, + 'method_response_metrics', + params.row.sample_method.method_response_metric_id + ); + + return value; + } + }, + { + field: 'start_date', + headerName: 'Start date', + flex: 1, + renderCell: (params) => ( + {dayjs(params.row.start_date).format(DATE_FORMAT.MediumDateFormat)} + ) + }, + { + field: 'start_time', + headerName: 'Start time', + flex: 1 + }, + { + field: 'end_date', + headerName: 'End date', + flex: 1, + renderCell: (params) => ( + {dayjs(params.row.end_date).format(DATE_FORMAT.MediumDateFormat)} + ) + }, + { + field: 'end_time', + headerName: 'End time', + flex: 1 + }, + { + field: 'duration', + headerName: 'Duration', + flex: 1, + valueGetter: (params) => { + const { start_date, start_time, end_date, end_time } = params.row; + + if (!start_date || !end_date) { + return null; + } + + return formatTimeDifference(start_date, start_time, end_date, end_time); + } + } + ]; + + return ( + 'auto'} + rows={periods} + getRowId={(row: IFindSamplePeriodRecord) => row.survey_sample_period_id} + columns={columns} + checkboxSelection={false} + disableRowSelectionOnClick + rowCount={rowCount} + paginationMode="server" + sortingMode="server" + sortModel={sortModel} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} + initialState={{ + pagination: { + paginationModel + } + }} + pageSizeOptions={[10, 25, 50]} + /> + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx b/app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx similarity index 55% rename from app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx rename to app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx index 521efccde4..6d19e0691c 100644 --- a/app/src/features/surveys/view/components/sampling-data/components/SurveySitesTable.tsx +++ b/app/src/features/surveys/view/components/sampling-data/components/site/SurveySitesTable.tsx @@ -1,27 +1,40 @@ import Box from '@mui/material/Box'; import blueGrey from '@mui/material/colors/blueGrey'; -import { GridColDef } from '@mui/x-data-grid'; +import { GridColDef, GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; import { StyledDataGrid } from 'components/data-grid/StyledDataGrid'; import { ISamplingSiteRowData } from 'features/surveys/sampling-information/sites/table/SamplingSiteTable'; -import { Feature } from 'geojson'; +import { IFindSampleSiteRecord } from 'interfaces/useSamplingSiteApi.interface'; import { getSamplingSiteSpatialType } from 'utils/spatial-utils'; -export interface ISurveySitesRowData { - id: number; - name: string; - description: string; - geojson: Feature; - blocks: string[]; - stratums: string[]; -} +const pageSizeOptions = [10, 25, 50]; export interface ISurveySitesTableProps { - sites: ISurveySitesRowData[]; + sites: IFindSampleSiteRecord[]; + paginationModel: GridPaginationModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + sortModel: GridSortModel; + rowCount: number; } +/** + * Renders a table of survey sampling sites. + * + * @param {ISurveySitesTableProps} props + * @return {*} + */ export const SurveySitesTable = (props: ISurveySitesTableProps) => { - const { sites } = props; + const { sites, paginationModel, setPaginationModel, sortModel, setSortModel, rowCount } = props; + + const rows: ISamplingSiteRowData[] = sites.map((site) => ({ + id: site.survey_sample_site_id, + name: site.name, + geometry_type: site.geometry_type, + description: site.description || '', + blocks: site.blocks.map((block) => block.name), + stratums: site.stratums.map((stratum) => stratum.name) + })); const columns: GridColDef[] = [ { @@ -32,11 +45,11 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { { field: 'geometry_type', headerName: 'Geometry', - flex: 1, + flex: 0.75, renderCell: (params) => ( @@ -50,7 +63,7 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { { field: 'blocks', headerName: 'Blocks', - flex: 1, + flex: 0.75, renderCell: (params) => ( {params.row.blocks.map((block) => ( @@ -64,11 +77,11 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { { field: 'stratums', headerName: 'Strata', - flex: 1, + flex: 0.75, renderCell: (params) => ( {params.row.stratums.map((stratum) => ( - + ))} @@ -81,18 +94,25 @@ export const SurveySitesTable = (props: ISurveySitesTableProps) => { 'auto'} - rows={sites} + rows={rows} getRowId={(row) => row.id} columns={columns} disableRowSelectionOnClick + onPaginationModelChange={setPaginationModel} + onSortModelChange={setSortModel} + sortModel={sortModel} + paginationModel={paginationModel} + paginationMode="server" + sortingMode="server" + rowCount={rowCount} initialState={{ pagination: { - paginationModel: { page: 1, pageSize: 10 } + paginationModel } }} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={pageSizeOptions} /> ); }; diff --git a/app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniqueCardContainer.tsx b/app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniqueCardContainer.tsx new file mode 100644 index 0000000000..2a479bbb1b --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/technique/SurveyTechniqueCardContainer.tsx @@ -0,0 +1,98 @@ +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; +import Stack from '@mui/material/Stack'; +import TablePagination from '@mui/material/TablePagination'; +import { GridPaginationModel, GridSortModel } from '@mui/x-data-grid'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useDataLoader from 'hooks/useDataLoader'; +import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; +import { useEffect } from 'react'; +import { SurveyTechniqueCard } from './components/SurveyTechniqueCard'; + +const pageSizeOptions = [10, 25, 50]; + +export interface ISurveyTechniquesCardContainerProps { + techniques: IGetTechniqueResponse[]; + paginationModel: GridPaginationModel; + sortModel: GridSortModel; + setPaginationModel: React.Dispatch>; + setSortModel: React.Dispatch>; + rowCount: number; +} + +export const SurveyTechniquesCardContainer = (props: ISurveyTechniquesCardContainerProps) => { + const { techniques, paginationModel, setPaginationModel, rowCount } = props; + + const biohubApi = useBiohubApi(); + // Get method attributes for relevant method lookup ids + const methodAttributeDataLoader = useDataLoader(() => + biohubApi.reference.getTechniqueAttributes(techniques.map((technique) => technique.method_lookup_id)) + ); + + useEffect(() => { + methodAttributeDataLoader.load(); + }, [methodAttributeDataLoader]); + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setPaginationModel({ page: 0, pageSize: parseInt(event.target.value, 10) }); + }; + + const handleChangePage = (_: React.MouseEvent | null, newPage: number) => { + setPaginationModel((model) => ({ ...model, page: newPage })); + }; + + return ( + <> + + + {techniques.map((technique) => { + const attributes = methodAttributeDataLoader.data?.find( + (method) => method.method_lookup_id === technique.method_lookup_id + ); + + if (attributes) { + return ( + + ); + } + })} + + + + + + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/technique/components/SurveyTechniqueCard.tsx b/app/src/features/surveys/view/components/sampling-data/components/technique/components/SurveyTechniqueCard.tsx new file mode 100644 index 0000000000..1eafca4d88 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/technique/components/SurveyTechniqueCard.tsx @@ -0,0 +1,121 @@ +import { PaperProps } from '@mui/material'; +import blueGrey from '@mui/material/colors/blueGrey'; +import grey from '@mui/material/colors/grey'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import ColouredRectangleChip from 'components/chips/ColouredRectangleChip'; +import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; +import { useCodesContext } from 'hooks/useContext'; +import { ITechniqueAttributeQualitative, ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; +import { IGetTechniqueResponse } from 'interfaces/useTechniqueApi.interface'; +import QualitativeAttributes from './qualitative/TechniqueCardQualitativeAttributes'; +import QuantitativeAttributes from './quantitative/TechniqueCardQuantitativeAttribute'; + +interface ISurveyTechniqueCardProps extends PaperProps { + technique: IGetTechniqueResponse; + methodAttributes: { + quantitative: ITechniqueAttributeQuantitative[]; + qualitative: ITechniqueAttributeQualitative[]; + }; +} + +/** + * Returns an expandable card with information about a survey technique, including its attributes and attractants + * + * @param {ISurveyTechniqueCardProps} props + * @returns {*} + */ +export const SurveyTechniqueCard = ({ + technique, + methodAttributes, + ...accordionStandardCardProps +}: ISurveyTechniqueCardProps) => { + const { codesDataLoader } = useCodesContext(); + const methodLookupName = + codesDataLoader.data?.sample_methods.find((method) => method.id === technique.method_lookup_id)?.name ?? ''; + + const attributesCount = + technique.attributes.qualitative_attributes.length + technique.attributes.quantitative_attributes.length; + const attractantsCount = technique.attractants.length; + + return ( + + {technique.distance_threshold && ( + + )} + + + } + {...accordionStandardCardProps}> + {technique.description} + + + Attributes  + ({attributesCount}) + + } + colour={grey[200]} + sx={{ my: 2 }}> + {attributesCount === 0 ? ( + + No attributes selected + + ) : ( + + {technique.attributes.qualitative_attributes.length > 0 && ( + + )} + {technique.attributes.quantitative_attributes.length > 0 && ( + + )} + + )} + + + + Attractants  + ({attractantsCount}) + + } + colour={grey[200]} + sx={{ my: 2 }}> + {attractantsCount > 0 ? ( + + {technique.attractants.map((attractant) => { + const attractantName = + codesDataLoader.data?.attractants.find((lookup) => lookup.id === attractant.attractant_lookup_id) + ?.name ?? ''; + + return ( + + ); + })} + + ) : ( + + No attractants selected + + )} + + + ); +}; diff --git a/app/src/features/surveys/view/components/sampling-data/components/technique/components/qualitative/TechniqueCardQualitativeAttributes.tsx b/app/src/features/surveys/view/components/sampling-data/components/technique/components/qualitative/TechniqueCardQualitativeAttributes.tsx new file mode 100644 index 0000000000..5ffdcc8562 --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/technique/components/qualitative/TechniqueCardQualitativeAttributes.tsx @@ -0,0 +1,55 @@ +import { grey } from '@mui/material/colors'; +import Typography from '@mui/material/Typography'; +import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; +import { ITechniqueAttributeQualitative } from 'interfaces/useReferenceApi.interface'; +import { TechniqueQualitativeAttribute } from 'interfaces/useTechniqueApi.interface'; + +interface TechniqueCardQualitativeAttributesProps { + qualitativeAttributes: TechniqueQualitativeAttribute[]; + methodAttributes: { + qualitative: ITechniqueAttributeQualitative[]; + }; +} + +/** + * Renders qualitative attributes for a technique. + * + * @param {TechniqueCardQualitativeAttributesProps} props + * @returns {*} + */ +const TechniqueCardQualitativeAttributes = ({ + qualitativeAttributes, + methodAttributes +}: TechniqueCardQualitativeAttributesProps) => { + return ( + <> + {qualitativeAttributes.map((attribute) => { + const attributeLookup = methodAttributes.qualitative.find( + (lookup) => lookup.method_lookup_attribute_qualitative_id === attribute.method_lookup_attribute_qualitative_id + ); + + const attributeName = attributeLookup?.name; + const attributeValue = attributeLookup?.options.find( + (option) => + option.method_lookup_attribute_qualitative_option_id === + attribute.method_lookup_attribute_qualitative_option_id + )?.name; + + return ( + + {attributeName}:  + {attributeValue} + + } + colour={grey[300]} + /> + ); + })} + + ); +}; + +export default TechniqueCardQualitativeAttributes; diff --git a/app/src/features/surveys/view/components/sampling-data/components/technique/components/quantitative/TechniqueCardQuantitativeAttribute.tsx b/app/src/features/surveys/view/components/sampling-data/components/technique/components/quantitative/TechniqueCardQuantitativeAttribute.tsx new file mode 100644 index 0000000000..2b26c2d89d --- /dev/null +++ b/app/src/features/surveys/view/components/sampling-data/components/technique/components/quantitative/TechniqueCardQuantitativeAttribute.tsx @@ -0,0 +1,54 @@ +import { grey } from '@mui/material/colors'; +import Typography from '@mui/material/Typography'; +import { AccordionStandardCard } from 'features/standards/view/components/AccordionStandardCard'; +import { ITechniqueAttributeQuantitative } from 'interfaces/useReferenceApi.interface'; +import { TechniqueQuantitativeAttribute } from 'interfaces/useTechniqueApi.interface'; + +interface TechniqueCardQuantitativeAttributesProps { + quantitativeAttributes: TechniqueQuantitativeAttribute[]; + methodAttributes: { + quantitative: ITechniqueAttributeQuantitative[]; + }; +} + +/** + * Renders quantitative attributes for a technique. + * + * @param {TechniqueCardQuantitativeAttributesProps} props + * @returns {*} + */ +const TechniqueCardQuantitativeAttributes = ({ + quantitativeAttributes, + methodAttributes +}: TechniqueCardQuantitativeAttributesProps) => { + return ( + <> + {quantitativeAttributes.map((attribute) => { + const attributeLookup = methodAttributes.quantitative.find( + (lookup) => + lookup.method_lookup_attribute_quantitative_id === attribute.method_lookup_attribute_quantitative_id + ); + + if (attributeLookup) { + return ( + + {attributeLookup.name}:  + + {attribute.value} ({attributeLookup.unit}) + + + } + colour={grey[300]} + /> + ); + } + return null; + })} + + ); +}; + +export default TechniqueCardQuantitativeAttributes; diff --git a/app/src/features/surveys/view/survey-animals/animal.ts b/app/src/features/surveys/view/survey-animals/animal.ts index fb1341dabb..38c3e75114 100644 --- a/app/src/features/surveys/view/survey-animals/animal.ts +++ b/app/src/features/surveys/view/survey-animals/animal.ts @@ -1,6 +1,7 @@ import { DATE_LIMIT } from 'constants/dateTimeFormats'; import { default as dayjs } from 'dayjs'; import { + ILocationCreate, IQualitativeMeasurementCreate, IQualitativeMeasurementUpdate, IQuantitativeMeasurementCreate, @@ -82,6 +83,19 @@ export const CreateCritterCaptureSchema = yup.object({ release_comment: yup.string().optional() }); +export const CreateBulkCritterCaptureSchema = yup.object({ + capture_id: yup.string().optional(), + critter_id: yup.string().required('Required'), + capture_location_id: yup.string().uuid().optional(), + release_location_id: yup.string().uuid().optional(), + capture_comment: yup.string().optional(), + capture_date: yup.string().required('Required'), + capture_time: yup.string().optional().nullable(), + release_date: yup.string().optional().nullable(), + release_time: yup.string().optional().nullable(), + release_comment: yup.string().optional() +}); + export const CreateCritterSchema = yup.object({ critter_id: yup.string().optional(), itis_tsn: yup.number().required('Required'), @@ -152,6 +166,7 @@ export type ICreateCritterMarking = yup.InferType; export type ICreateCritterCollectionUnit = yup.InferType & { key?: string }; export type ICreateCritterCapture = yup.InferType; +export type ICreateBulkCritterCapture = yup.InferType; export type ICreateCritterFamily = yup.InferType; export type ICreateCritterMortality = yup.InferType; @@ -162,7 +177,8 @@ export type IBulkCreate = { critters?: ICreateCritter[]; qualitative_measurements?: IQualitativeMeasurementCreate[]; quantitative_measurements?: IQuantitativeMeasurementCreate[]; - captures?: ICreateCritterCapture[]; + locations?: ILocationCreate[]; + captures?: ICreateBulkCritterCapture[]; mortality?: ICreateCritterMortality; markings?: ICreateCritterMarking[]; collections?: ICreateCritterCollectionUnit[]; diff --git a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx index db559114d6..81ce4855f9 100644 --- a/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx +++ b/app/src/features/surveys/view/survey-spatial/SurveySpatialContainer.tsx @@ -9,7 +9,9 @@ import { import { SurveySpatialTelemetry } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry'; import { useObservationsContext, useTaxonomyContext } from 'hooks/useContext'; import { isEqual } from 'lodash-es'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useSamplingSiteStaticLayer } from './components/map/useSamplingSiteStaticLayer'; +import { useStudyAreaStaticLayer } from './components/map/useStudyAreaStaticLayer'; /** * Container component for displaying survey spatial data. @@ -24,6 +26,14 @@ export const SurveySpatialContainer = (): JSX.Element => { const [activeView, setActiveView] = useState(SurveySpatialDatasetViewEnum.OBSERVATIONS); + const studyAreaStaticLayer = useStudyAreaStaticLayer(); + const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); + + const staticLayers = useMemo( + () => [studyAreaStaticLayer, samplingSiteStaticLayer], + [samplingSiteStaticLayer, studyAreaStaticLayer] + ); + // Fetch and cache all taxonomic data required for the observations. useEffect(() => { const cacheTaxonomicData = async () => { @@ -49,35 +59,22 @@ export const SurveySpatialContainer = (): JSX.Element => { activeView={activeView} setActiveView={setActiveView} views={[ - { - label: 'Observations', - value: SurveySpatialDatasetViewEnum.OBSERVATIONS, - icon: mdiEye, - isLoading: false - }, - { - label: 'Animals', - value: SurveySpatialDatasetViewEnum.ANIMALS, - icon: mdiPaw, - isLoading: false - }, - { - label: 'Telemetry', - value: SurveySpatialDatasetViewEnum.TELEMETRY, - icon: mdiWifiMarker, - isLoading: false - } + { value: SurveySpatialDatasetViewEnum.OBSERVATIONS, label: 'Observations', icon: mdiEye }, + { value: SurveySpatialDatasetViewEnum.ANIMALS, label: 'Animals', icon: mdiPaw }, + { value: SurveySpatialDatasetViewEnum.TELEMETRY, label: 'Telemetry', icon: mdiWifiMarker } ]} /> {/* Display the corresponding dataset view based on the selected active view */} - {isEqual(SurveySpatialDatasetViewEnum.OBSERVATIONS, activeView) && } + {isEqual(SurveySpatialDatasetViewEnum.OBSERVATIONS, activeView) && ( + + )} {isEqual(SurveySpatialDatasetViewEnum.TELEMETRY, activeView) && ( - + )} - {isEqual(SurveySpatialDatasetViewEnum.ANIMALS, activeView) && } + {isEqual(SurveySpatialDatasetViewEnum.ANIMALS, activeView) && } ); }; diff --git a/app/src/features/surveys/view/survey-spatial/components/SurveySpatialToolbar.tsx b/app/src/features/surveys/view/survey-spatial/components/SurveySpatialToolbar.tsx index 285d71a019..117c39bedc 100644 --- a/app/src/features/surveys/view/survey-spatial/components/SurveySpatialToolbar.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/SurveySpatialToolbar.tsx @@ -7,12 +7,14 @@ import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import Menu, { MenuProps } from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; +import Stack from '@mui/material/Stack'; import Toolbar from '@mui/material/Toolbar'; import Typography from '@mui/material/Typography'; +import HelpButtonDialog from 'components/buttons/HelpButtonDialog'; import { ProjectRoleGuard } from 'components/security/Guards'; +import CustomToggleButtonGroup, { ToggleButtonView } from 'components/toolbar/CustomToggleButtonGroup'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; +import { MarkdownTypeNameEnum } from 'interfaces/useMarkdownApi.interface'; import { useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; @@ -22,17 +24,10 @@ export enum SurveySpatialDatasetViewEnum { ANIMALS = 'ANIMALS' } -interface ISurveySpatialDatasetView { - label: string; - icon: string; - value: SurveySpatialDatasetViewEnum; - isLoading: boolean; -} - interface ISurveySpatialToolbarProps { activeView: SurveySpatialDatasetViewEnum; setActiveView: (view: SurveySpatialDatasetViewEnum) => void; - views: ISurveySpatialDatasetView[]; + views: ToggleButtonView[]; } /** @@ -46,14 +41,6 @@ export const SurveySpatialToolbar = (props: ISurveySpatialToolbarProps) => { const [anchorEl, setAnchorEl] = useState(null); - const updateDatasetView = (_event: React.MouseEvent, view: SurveySpatialDatasetViewEnum) => { - if (!view) { - return; - } - - setActiveView(view); - }; - const handleMenuClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); }; @@ -120,53 +107,31 @@ export const SurveySpatialToolbar = (props: ISurveySpatialToolbarProps) => { Survey Data - - - + + + + + + - - {views.map((view) => ( - } - value={view.value}> - {view.label} - - ))} - + setActiveView(view)} + orientation="horizontal" + /> diff --git a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx index d1688bd46c..c31ad9f9d5 100644 --- a/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimal.tsx @@ -4,7 +4,7 @@ import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; import { SurveySpatialAnimalCapturePopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalCapturePopup'; import { SurveySpatialAnimalMortalityPopup } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalMortalityPopup'; import { SurveySpatialAnimalTable } from 'features/surveys/view/survey-spatial/components/animal/SurveySpatialAnimalTable'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { useSurveyContext } from 'hooks/useContext'; import { useCritterbaseApi } from 'hooks/useCritterbaseApi'; @@ -12,11 +12,18 @@ import useDataLoader from 'hooks/useDataLoader'; import { useEffect, useMemo } from 'react'; import { coloredCustomMortalityMarker } from 'utils/mapUtils'; +interface ISurveySpatialAnimalProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component for displaying animal capture points on a map and in a table. * Retrieves and displays data related to animal captures for a specific survey. */ -export const SurveySpatialAnimal = () => { +export const SurveySpatialAnimal = (props: ISurveySpatialAnimalProps) => { const surveyContext = useSurveyContext(); const crittersApi = useCritterbaseApi(); @@ -91,11 +98,14 @@ export const SurveySpatialAnimal = () => { <> {/* Display map with animal capture points */} - + {/* Display data table with animal capture details */} - + diff --git a/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx b/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx deleted file mode 100644 index ce916ecbc1..0000000000 --- a/app/src/features/surveys/view/survey-spatial/components/map/SurveySpatialMap.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { IStaticLayer } from 'components/map/components/StaticLayers'; -import { useSamplingSiteStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer'; -import { useStudyAreaStaticLayer } from 'features/surveys/view/survey-spatial/components/map/useStudyAreaStaticLayer'; -import SurveyMap from 'features/surveys/view/SurveyMap'; -import { useMemo } from 'react'; - -/** - * Props interface for SurveySpatialMap component. - */ -interface ISurveyDataMapProps { - /** - * Array of additional static layers to be added to the map. - */ - staticLayers: IStaticLayer[]; - /** - * Loading indicator to control map skeleton loader. - */ - isLoading: boolean; -} - -/** - * Component for displaying survey-related spatial data on a map. - * - * Automatically includes the study area and sampling site static layers. - * - * @param {ISurveyDataMapProps} props - * @return {*} - */ -export const SurveySpatialMap = (props: ISurveyDataMapProps) => { - const { staticLayers, isLoading } = props; - - const studyAreaStaticLayer = useStudyAreaStaticLayer(); - - const samplingSiteStaticLayer = useSamplingSiteStaticLayer(); - - const allStaticLayers = useMemo( - () => [studyAreaStaticLayer, samplingSiteStaticLayer, ...staticLayers], - [samplingSiteStaticLayer, staticLayers, studyAreaStaticLayer] - ); - - return ; -}; diff --git a/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx index 9fdf33aa54..325903a7d6 100644 --- a/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/map/useSamplingSiteStaticLayer.tsx @@ -1,8 +1,11 @@ import { IStaticLayer } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; -import { SurveyMapPopup } from 'features/surveys/view/SurveyMapPopup'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; +import { SurveySampleSiteMapPopup } from 'features/surveys/view/SurveySampleSiteMapPopup'; +import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; +import useDataLoader from 'hooks/useDataLoader'; +import { useEffect } from 'react'; import { Popup } from 'react-leaflet'; /** @@ -12,6 +15,17 @@ import { Popup } from 'react-leaflet'; */ export const useSamplingSiteStaticLayer = (): IStaticLayer => { const surveyContext = useSurveyContext(); + const biohubApi = useBiohubApi(); + + const geometryDataLoader = useDataLoader(() => + biohubApi.samplingSite.getSampleSitesGeometry(surveyContext.projectId, surveyContext.surveyId) + ); + + useEffect(() => { + geometryDataLoader.load(); + }, [geometryDataLoader]); + + const samplingSites = geometryDataLoader.data?.sampleSites ?? []; const samplingSiteStaticLayer: IStaticLayer = { layerName: 'Sampling Sites', @@ -20,7 +34,7 @@ export const useSamplingSiteStaticLayer = (): IStaticLayer => { fillColor: SURVEY_MAP_LAYER_COLOURS.SAMPLING_SITE_COLOUR }, features: - surveyContext.sampleSiteDataLoader.data?.sampleSites.flatMap((site) => { + samplingSites.flatMap((site) => { return { id: site.survey_sample_site_id, key: `sampling-site-${site.survey_sample_site_id}`, @@ -28,32 +42,9 @@ export const useSamplingSiteStaticLayer = (): IStaticLayer => { }; }) ?? [], popup: (feature) => { - const sampleSite = surveyContext.sampleSiteDataLoader.data?.sampleSites.find( - (item) => item.survey_sample_site_id === feature.id - ); - - const metadata = []; - - if (sampleSite) { - metadata.push({ - label: 'Name', - value: sampleSite.name - }); - - metadata.push({ - label: 'Description', - value: sampleSite.description - }); - } - return ( - + ); }, diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx index 426c266a1f..a12f2b6630 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservation.tsx @@ -2,8 +2,8 @@ import Box from '@mui/material/Box'; import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; import SurveyObservationTabularDataContainer from 'features/surveys/view/components/data-container/SurveyObservationTabularDataContainer'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; import { SurveySpatialObservationPointPopup } from 'features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationPointPopup'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { useBiohubApi } from 'hooks/useBioHubApi'; import { useSurveyContext } from 'hooks/useContext'; @@ -12,10 +12,17 @@ import { IGetSurveyObservationsGeometryResponse } from 'interfaces/useObservatio import { useEffect, useMemo } from 'react'; import { coloredCustomObservationMarker } from 'utils/mapUtils'; +interface ISurveySpatialObservationProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component to display survey observation data on a map and in a table. */ -export const SurveySpatialObservation = () => { +export const SurveySpatialObservation = (props: ISurveySpatialObservationProps) => { const surveyContext = useSurveyContext(); const { surveyId, projectId } = surveyContext; const biohubApi = useBiohubApi(); @@ -62,12 +69,15 @@ export const SurveySpatialObservation = () => { <> {/* Display map with observation points */} - + {/* Display data table with observation details */} - - + + ); diff --git a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx index a7afdece4e..ecef0ea336 100644 --- a/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/observation/SurveySpatialObservationTable.tsx @@ -28,17 +28,12 @@ interface IObservationTableRow { longitude: number | null; } -interface ISurveyDataObservationTableProps { - isLoading: boolean; -} - /** * Component to display observation data in a table with server-side pagination and sorting. * - * @param {ISurveyDataObservationTableProps} props - Component properties. - * @returns {JSX.Element} The rendered component. + * @returns {*} */ -export const SurveySpatialObservationTable = (props: ISurveyDataObservationTableProps) => { +export const SurveySpatialObservationTable = () => { const biohubApi = useBiohubApi(); const surveyContext = useContext(SurveyContext); const taxonomyContext = useTaxonomyContext(); @@ -160,7 +155,7 @@ export const SurveySpatialObservationTable = (props: ISurveyDataObservationTable return ( } isLoadingFallbackDelay={100} hasNoData={!rows.length} @@ -195,6 +190,7 @@ export const SurveySpatialObservationTable = (props: ISurveyDataObservationTable rowSelection={false} autoHeight={false} checkboxSelection={false} + disableRowSelectionOnClick disableColumnSelector disableColumnFilter disableColumnMenu diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx index 25011e2585..db47693eca 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetry.tsx @@ -1,9 +1,9 @@ import Box from '@mui/material/Box'; import { IStaticLayer, IStaticLayerFeature } from 'components/map/components/StaticLayers'; import { SURVEY_MAP_LAYER_COLOURS } from 'constants/colours'; -import { SurveySpatialMap } from 'features/surveys/view/survey-spatial/components/map/SurveySpatialMap'; import { SurveySpatialTelemetryPopup } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryPopup'; import { SurveySpatialTelemetryTable } from 'features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable'; +import SurveyMap from 'features/surveys/view/SurveyMap'; import SurveyMapTooltip from 'features/surveys/view/SurveyMapTooltip'; import { Position } from 'geojson'; import { useSurveyContext, useTelemetryDataContext } from 'hooks/useContext'; @@ -11,12 +11,19 @@ import { ICritterSimpleResponse } from 'interfaces/useCritterApi.interface'; import { IAnimalDeployment, ITelemetry } from 'interfaces/useTelemetryApi.interface'; import { useCallback, useEffect, useMemo } from 'react'; +interface ISurveySpatialTelemetryProps { + /** + * Array of additional static layers to be added to the map. + */ + staticLayers: IStaticLayer[]; +} + /** * Component to display telemetry data on a map and in a table. * - * @returns {JSX.Element} The rendered component. + * @returns {*} The rendered component. */ -export const SurveySpatialTelemetry = () => { +export const SurveySpatialTelemetry = (props: ISurveySpatialTelemetryProps) => { const surveyContext = useSurveyContext(); const telemetryDataContext = useTelemetryDataContext(); @@ -131,12 +138,12 @@ export const SurveySpatialTelemetry = () => { <> {/* Display map with telemetry points */} - + {/* Display data table with telemetry details */} - - + + ); diff --git a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx index c36cad35b5..42e09d47a3 100644 --- a/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx +++ b/app/src/features/surveys/view/survey-spatial/components/telemetry/SurveySpatialTelemetryTable.tsx @@ -29,18 +29,12 @@ interface ITelemetryData { itis_scientific_name: string; } -interface ISurveyDataTelemetryTableProps { - isLoading: boolean; -} - /** * Component to display telemetry data in a table format. * - * @param {ISurveyDataTelemetryTableProps} props - The component props. - * @param {boolean} props.isLoading - Indicates if the data is currently loading. - * @returns {JSX.Element} The rendered component. + * @returns {*} The rendered component. */ -export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProps) => { +export const SurveySpatialTelemetryTable = () => { const surveyContext = useContext(SurveyContext); const telemetryDataContext = useTelemetryDataContext(); @@ -170,7 +164,7 @@ export const SurveySpatialTelemetryTable = (props: ISurveyDataTelemetryTableProp return ( } isLoadingFallbackDelay={100} hasNoData={!rows.length} diff --git a/app/src/hooks/api/useAlertApi.ts b/app/src/hooks/api/useAlertApi.ts new file mode 100644 index 0000000000..3dd402e7bc --- /dev/null +++ b/app/src/hooks/api/useAlertApi.ts @@ -0,0 +1,88 @@ +import { AxiosInstance } from 'axios'; +import { + IAlert, + IAlertCreateObject, + IAlertFilterParams, + IAlertUpdateObject, + IGetAlertsResponse +} from 'interfaces/useAlertApi.interface'; +import qs from 'qs'; + +/** + * Returns a set of supported api methods for managing alerts + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +export const useAlertApi = (axios: AxiosInstance) => { + /** + * Get system alert details based on its ID for viewing purposes. + * + * @param {IAlertFilterParams} filterObject + * @return {*} {Promise} + */ + const getAlerts = async (filterObject?: IAlertFilterParams): Promise => { + const params = { + ...filterObject + }; + + const { data } = await axios.get(`/api/alert`, { + params: params, + paramsSerializer: (params: any) => { + return qs.stringify(params); + } + }); + + return data; + }; + + /** + * Get a specific alert for editing + * + * @param {number} alertId + * @return {*} {Promise} + */ + const getAlertById = async (alertId: number): Promise => { + const { data } = await axios.get(`/api/alert/${alertId}`); + + return data; + }; + + /** + * Create a new system alert + * + * @param {IAlertCreateObject} alert + * @return {*} {Promise} + */ + const createAlert = async (alert: IAlertCreateObject): Promise => { + const { data } = await axios.post(`/api/alert`, alert); + + return data; + }; + + /** + * Create a new system alert + * + * @param {IAlert} alert + * @return {*} {Promise<{ alert_id: number }>} + */ + const updateAlert = async (alert: IAlertUpdateObject): Promise<{ alert_id: number }> => { + const { data } = await axios.put(`/api/alert/${alert.alert_id}`, alert); + + return data; + }; + + /** + * Get system alert details based on its ID for viewing purposes. + * + * @param {number} alertId + * @return {*} {Promise<{ alert_id: number }>} + */ + const deleteAlert = async (alertId: number): Promise<{ alert_id: number }> => { + const { data } = await axios.delete(`/api/alert/${alertId}`); + + return data; + }; + + return { getAlerts, updateAlert, createAlert, deleteAlert, getAlertById }; +}; diff --git a/app/src/hooks/api/useAnimalApi.ts b/app/src/hooks/api/useAnimalApi.ts index 9adf107fee..451c8e770c 100644 --- a/app/src/hooks/api/useAnimalApi.ts +++ b/app/src/hooks/api/useAnimalApi.ts @@ -1,4 +1,4 @@ -import { AxiosInstance } from 'axios'; +import { AxiosInstance, AxiosProgressEvent, CancelTokenSource } from 'axios'; import { IAnimalsAdvancedFilters } from 'features/summary/tabular-data/animal/AnimalsListFilterForm'; import { IFindAnimalsResponse, IGetCaptureMortalityGeometryResponse } from 'interfaces/useAnimalApi.interface'; import qs from 'qs'; @@ -20,7 +20,7 @@ const useAnimalApi = (axios: AxiosInstance) => { * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise} + * @return {*} {Promise} */ const getCaptureMortalityGeometry = async ( projectId: number, @@ -54,7 +54,76 @@ const useAnimalApi = (axios: AxiosInstance) => { return data; }; - return { getCaptureMortalityGeometry, findAnimals }; + /** + * Uploads attachments for a Critter Capture and deletes existing attachments if provided. + * + * @async + * @param {*} params - Upload parameters. + * @returns {*} Promise + */ + const uploadCritterCaptureAttachments = async (params: { + projectId: number; + surveyId: number; + critterId: number; + critterbaseCaptureId: string; + files: File[]; + deleteIds?: number[]; + cancelTokenSource?: CancelTokenSource; + onProgress?: (progressEvent: AxiosProgressEvent) => void; + }) => { + const fileData = new FormData(); + + /** + * Add all the files to the request FormData + * + * Note: Multer expecting a single key of 'media' for the array of files, + * using `media[index]` will not work. + */ + params.files.forEach((file) => { + fileData.append(`media`, file); + }); + + // Add the existing attachment ids to delete + if (params.deleteIds?.length) { + params.deleteIds.forEach((id, idx) => { + fileData.append(`delete_ids[${idx}]`, id.toString()); + }); + } + + await axios.post( + `/api/project/${params.projectId}/survey/${params.surveyId}/critters/${params.critterId}/captures/${params.critterbaseCaptureId}/attachments/upload`, + fileData, + { + cancelToken: params.cancelTokenSource?.token, + onUploadProgress: params.onProgress + } + ); + }; + + /** + * Deletes all attachments for a Critter Capture. + * + * @async + * @param {*} params - Delete parameters. + * @returns {*} Promise + */ + const deleteCaptureAttachments = async (params: { + projectId: number; + surveyId: number; + critterId: number; + critterbaseCaptureId: string; + }) => { + await axios.delete( + `/api/project/${params.projectId}/survey/${params.surveyId}/critters/${params.critterId}/captures/${params.critterbaseCaptureId}/attachments` + ); + }; + + return { + getCaptureMortalityGeometry, + findAnimals, + uploadCritterCaptureAttachments, + deleteCaptureAttachments + }; }; export default useAnimalApi; diff --git a/app/src/hooks/api/useMarkdownApi.ts b/app/src/hooks/api/useMarkdownApi.ts new file mode 100644 index 0000000000..f40b86fe18 --- /dev/null +++ b/app/src/hooks/api/useMarkdownApi.ts @@ -0,0 +1,43 @@ +import { AxiosInstance } from 'axios'; +import { IGetMarkdownResponse, IMarkdownFilterObject, MarkdownScoreObject } from 'interfaces/useMarkdownApi.interface'; +import qs from 'qs'; + +/** + * Returns a set of supported api methods for working with observations. + * + * @param {AxiosInstance} axios + * @return {*} object whose properties are supported api methods. + */ +export const useMarkdownApi = (axios: AxiosInstance) => { + /** + * Get observations for a system user id. + * + * @param {IMarkdownFilterObject} filterObject + * @return {Promise} + */ + const getMarkdown = async (filterObject: IMarkdownFilterObject): Promise => { + const params = { + ...filterObject + }; + + const { data } = await axios.get('/api/markdown', { params, paramsSerializer: (params) => qs.stringify(params) }); + + return data; + }; + + /** + * Score to increase or decrease the score of the markdown + * + * @param {MarkdownScoreObject} markdownScoreObject + * @return {Promise} + */ + const insertScore = async (markdownScoreObject: MarkdownScoreObject): Promise => { + const { data } = await axios.post(`/api/markdown/${markdownScoreObject.markdownId}`, { + score: markdownScoreObject.score + }); + + return data; + }; + + return { getMarkdown, insertScore }; +}; diff --git a/app/src/hooks/api/useObservationApi.test.ts b/app/src/hooks/api/useObservationApi.test.ts index 0b2e4173ff..eeec7a8e78 100644 --- a/app/src/hooks/api/useObservationApi.test.ts +++ b/app/src/hooks/api/useObservationApi.test.ts @@ -40,7 +40,8 @@ describe('useObservationApi', () => { qualitative_measurements: [], quantitative_measurements: [], qualitative_environments: [], - quantitative_environments: [] + quantitative_environments: [], + sample_sites: [] }, pagination: { total: 100, diff --git a/app/src/hooks/api/useObservationApi.ts b/app/src/hooks/api/useObservationApi.ts index 525d6e483f..702a76afca 100644 --- a/app/src/hooks/api/useObservationApi.ts +++ b/app/src/hooks/api/useObservationApi.ts @@ -19,6 +19,7 @@ import { ApiPaginationRequestOptions } from 'types/misc'; export interface SubcountToSave { observation_subcount_id: number | null; subcount: number | null; + comment: string | null; qualitative_measurements: { measurement_id: string; measurement_option_id: string; @@ -104,23 +105,15 @@ const useObservationApi = (axios: AxiosInstance) => { surveyId: number, pagination?: ApiPaginationRequestOptions ): Promise => { - let urlParamsString = ''; - - if (pagination) { - const params = new URLSearchParams(); - params.append('page', pagination.page.toString()); - params.append('limit', pagination.limit.toString()); - if (pagination.sort) { - params.append('sort', pagination.sort); - } - if (pagination.order) { - params.append('order', pagination.order); - } - urlParamsString = `?${params.toString()}`; - } + const params = { + ...pagination + }; const { data } = await axios.get( - `/api/project/${projectId}/survey/${surveyId}/observations${urlParamsString}` + `/api/project/${projectId}/survey/${surveyId}/observations`, + { + params + } ); return data; diff --git a/app/src/hooks/api/useSamplingSiteApi.ts b/app/src/hooks/api/useSamplingSiteApi.ts index a8f7783595..a53a84c475 100644 --- a/app/src/hooks/api/useSamplingSiteApi.ts +++ b/app/src/hooks/api/useSamplingSiteApi.ts @@ -2,9 +2,13 @@ import { AxiosInstance } from 'axios'; import { ICreateSamplingSiteRequest, IEditSampleSiteRequest, + IFindSamplePeriodResponse, + IFindSampleSiteResponse, IGetSampleLocationDetails, - IGetSampleSiteResponse + IGetSampleLocationNonSpatialResponse, + IGetSampleSiteGeometryResponse } from 'interfaces/useSamplingSiteApi.interface'; +import { ApiPaginationRequestOptions } from 'types/misc'; /** * Returns a set of supported api methods for working with search functionality @@ -30,14 +34,45 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { }; /** - * Get Sample Sites + * Get Sample Sites, paginated or filtered by keyword. * * @param {number} projectId * @param {number} surveyId - * @return {*} {Promise} + * @param {ApiPaginationRequestOptions} pagination + * @return {*} {Promise} + */ + const getSampleSites = async ( + projectId: number, + surveyId: number, + options?: { + keyword?: string; + pagination?: ApiPaginationRequestOptions; + } + ): Promise => { + const params = { + keyword: options?.keyword, + ...options?.pagination + }; + + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site`, { + params + }); + + return data; + }; + + /** + * Get Sample Sites geometry data + * + * @param {number} projectId + * @param {number} surveyId + * @return {*} {Promise} */ - const getSampleSites = async (projectId: number, surveyId: number): Promise => { - const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site`); + const getSampleSitesGeometry = async ( + projectId: number, + surveyId: number + ): Promise => { + const { data } = await axios.get(`/api/project/${projectId}/survey/${surveyId}/sample-site/spatial`); return data; }; @@ -48,7 +83,7 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { * @param {number} projectId * @param {number} surveyId * @param {number} sampleSiteId - * @return {*} {Promise} + * @return {*} {Promise} */ const getSampleSiteById = async ( projectId: number, @@ -59,6 +94,103 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { return data; }; + /** + * Find sample sites. + * + * @param {{ + * survey_id?: number; + * keyword?: string; + * system_user_id?: number; + * }} [filterFieldData] + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const findSampleSites = async ( + filterFieldData?: { + survey_id?: number; + keyword?: string; + system_user_id?: number; + }, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...filterFieldData, + ...pagination + }; + + const { data } = await axios.get(`/api/sampling-locations/sites`, { + params + }); + + return data; + }; + + /** + * Find sample methods. + * + * @param {{ + * survey_id?: number; + * sample_site_id: number; + * keyword?: string; + * system_user_id?: number; + * }} [filterFieldData] + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const findSampleMethods = async ( + filterFieldData?: { + survey_id?: number; + sample_site_id: number; + keyword?: string; + system_user_id?: number; + }, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...filterFieldData, + ...pagination + }; + + const { data } = await axios.get(`/api/sampling-locations/methods`, { + params + }); + + return data; + }; + + /** + * Find sample periods. + * + * @param {{ + * survey_id?: number; + * sample_site_id: number; + * sample_method_id: number; + * system_user_id?: number; + * }} [filterFieldData] + * @param {ApiPaginationRequestOptions} [pagination] + * @return {*} {Promise} + */ + const findSamplePeriods = async ( + filterFieldData?: { + survey_id?: number; + sample_site_id?: number; + sample_method_id?: number; + system_user_id?: number; + }, + pagination?: ApiPaginationRequestOptions + ): Promise => { + const params = { + ...filterFieldData, + ...pagination + }; + + const { data } = await axios.get(`/api/sampling-locations/periods`, { + params + }); + + return data; + }; + /** * Edit Sample Site * @@ -109,6 +241,10 @@ const useSamplingSiteApi = (axios: AxiosInstance) => { createSamplingSites, getSampleSites, getSampleSiteById, + getSampleSitesGeometry, + findSampleSites, + findSampleMethods, + findSamplePeriods, editSampleSite, deleteSampleSite, deleteSampleSites diff --git a/app/src/hooks/useBioHubApi.ts b/app/src/hooks/useBioHubApi.ts index 0115bd2df5..37786124f6 100644 --- a/app/src/hooks/useBioHubApi.ts +++ b/app/src/hooks/useBioHubApi.ts @@ -3,12 +3,14 @@ import useReferenceApi from 'hooks/api/useReferenceApi'; import { useConfigContext } from 'hooks/useContext'; import { useMemo } from 'react'; import useAdminApi from './api/useAdminApi'; +import { useAlertApi } from './api/useAlertApi'; import useAnalyticsApi from './api/useAnalyticsApi'; import useAnimalApi from './api/useAnimalApi'; import useAxios from './api/useAxios'; import useCodesApi from './api/useCodesApi'; import useExternalApi from './api/useExternalApi'; import useFundingSourceApi from './api/useFundingSourceApi'; +import { useMarkdownApi } from './api/useMarkdownApi'; import useObservationApi from './api/useObservationApi'; import useProjectApi from './api/useProjectApi'; import useProjectParticipationApi from './api/useProjectParticipationApi'; @@ -72,6 +74,10 @@ export const useBiohubApi = () => { const telemetry = useTelemetryApi(apiAxios); + const markdown = useMarkdownApi(apiAxios); + + const alert = useAlertApi(apiAxios); + return useMemo( () => ({ analytics, @@ -93,7 +99,9 @@ export const useBiohubApi = () => { samplingSite, standards, reference, - telemetry + telemetry, + markdown, + alert }), // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/src/hooks/useS3Download.tsx b/app/src/hooks/useS3Download.tsx new file mode 100644 index 0000000000..002d7e435d --- /dev/null +++ b/app/src/hooks/useS3Download.tsx @@ -0,0 +1,41 @@ +import { AttachmentsI18N } from 'constants/i18n'; +import { DialogContext } from 'contexts/dialogContext'; +import { useContext } from 'react'; +import { APIError } from './api/useAxios'; + +/** + * Hook for downloading a file from a S3 key. + * + */ +export const useS3FileDownload = () => { + const dialogContext = useContext(DialogContext); + + /** + * Download a file from a S3 key. + * + * @param {string} s3KeyOrPromise - The S3 key or a promise that resolves to the S3 key. + * @returns {*} {Promise} + */ + const downloadS3File = async (s3KeyOrPromise: Promise | string) => { + try { + const s3Key = await s3KeyOrPromise; + + window.open(s3Key); + } catch (error) { + const apiError = error as APIError; + + dialogContext.setErrorDialog({ + open: true, + onOk: () => dialogContext.setErrorDialog({ open: false }), + onClose: () => dialogContext.setErrorDialog({ open: false }), + dialogTitle: AttachmentsI18N.downloadErrorTitle, + dialogText: AttachmentsI18N.downloadErrorText, + dialogErrorDetails: apiError.errors + }); + } + }; + + return { + downloadS3File + }; +}; diff --git a/app/src/interfaces/useAlertApi.interface.ts b/app/src/interfaces/useAlertApi.interface.ts new file mode 100644 index 0000000000..deb5e5d6bd --- /dev/null +++ b/app/src/interfaces/useAlertApi.interface.ts @@ -0,0 +1,38 @@ +export interface IGetAlertsResponse { + alerts: IAlert[]; +} + +export type AlertSeverity = 'info' | 'success' | 'error' | 'warning'; +export interface IAlert { + alert_id: number; + alert_type_id: number; + severity: AlertSeverity; + name: string; + message: string; + data: object | null; + record_end_date: string | null; + status: 'expired' | 'active'; +} + +export type IAlertCreateObject = Omit; + +export type IAlertUpdateObject = Omit; + +export interface IAlertFilterParams { + expiresBefore?: string; + expiresAfter?: string; + types?: SystemAlertBannerEnum[]; +} + +export enum SystemAlertBannerEnum { + SUMMARY = 'Summary', + TELEMETRY = 'Manage Telemetry', + OBSERVATIONS = 'Manage Observations', + ANIMALS = 'Manage Animals', + SAMPLING = 'Manage Sampling', + PROJECTS = 'Project', + SURVEYS = 'Survey', + STANDARDS = 'Standards', + ADMINISTRATOR = 'Administrator', + FUNDING = 'Funding' +} diff --git a/app/src/interfaces/useCodesApi.interface.ts b/app/src/interfaces/useCodesApi.interface.ts index 61d67cc9ea..a053431566 100644 --- a/app/src/interfaces/useCodesApi.interface.ts +++ b/app/src/interfaces/useCodesApi.interface.ts @@ -41,4 +41,5 @@ export interface IGetAllCodeSetsResponse { method_response_metrics: CodeSet<{ id: number; name: string; description: string }>; attractants: CodeSet<{ id: number; name: string; description: string }>; observation_subcount_signs: CodeSet<{ id: number; name: string; description: string }>; + alert_types: CodeSet<{ id: number; name: string; description: string }>; } diff --git a/app/src/interfaces/useCritterApi.interface.ts b/app/src/interfaces/useCritterApi.interface.ts index 7e3e31db6a..64fdcdd73c 100644 --- a/app/src/interfaces/useCritterApi.interface.ts +++ b/app/src/interfaces/useCritterApi.interface.ts @@ -89,10 +89,22 @@ export interface IMeasurementsUpdate { } export interface ICreateCaptureRequest extends IMarkings, IMeasurementsCreate { + attachments: { + capture_attachments: { + create: Record; + delete?: never; + }; + }; capture: ICapturePostData; } export interface IEditCaptureRequest extends IMarkings, IMeasurementsUpdate { + attachments: { + capture_attachments: { + create: Record; + delete: number[]; + }; + }; capture: ICapturePostData; } @@ -109,6 +121,22 @@ export interface ICollectionUnitMultiTsnResponse { categories: ICollectionCategory[]; } +interface ICritterAttachmentBase { + uuid: string; + critter_id: number; + file_type: string; + file_name: string; + file_size: number; + title: string | null; + description: string | null; + key: string; +} + +export type ICritterCaptureAttachment = { + critter_capture_attachment_id: number; + critterbase_capture_id: string; +} & ICritterAttachmentBase; + export interface ICollectionCategory { collection_category_id: string; category_name: string; @@ -152,6 +180,19 @@ type ILocationResponse = { wmu_id: string | null; }; +export type ILocationCreate = { + location_id?: string; + latitude: number; + longitude: number; + coordinate_uncertainty?: number | null; + coordinate_uncertainty_unit?: string; + temperature?: number | null; + location_comment?: string | null; + region_env_id?: string | null; + region_nr_id?: string | null; + wmu_id?: string | null; +}; + export type ICaptureResponse = { capture_id: string; capture_date: string; @@ -299,6 +340,9 @@ export type ICritterDetailedResponse = { }; family_parent: IFamilyParentResponse[]; family_child: IFamilyChildResponse[]; + attachments: { + capture_attachments: ICritterCaptureAttachment[]; + }; }; export interface ICritterSimpleResponse { diff --git a/app/src/interfaces/useMarkdownApi.interface.ts b/app/src/interfaces/useMarkdownApi.interface.ts new file mode 100644 index 0000000000..c747042102 --- /dev/null +++ b/app/src/interfaces/useMarkdownApi.interface.ts @@ -0,0 +1,30 @@ +export interface IGetMarkdownResponse { + markdown: { + markdown_id: number; + markdown_type_id: number; + data: string; + participated: boolean; + }; +} +export interface IMarkdownFilterObject { + typeName: string; +} + +export interface MarkdownScoreObject { + markdownId: number; + score: number; +} + +export enum MarkdownTypeNameEnum { + PROJECTS_AND_SURVEYS = 'Projects and Surveys', + SUMMARY_DATA = 'Summary Data', + SAMPLING_INFORMATION = 'Sampling Information', + SURVEY_DATA = 'Survey Data', + PROJECT_DETAILS = 'Project Details', + SURVEYS = 'Surveys', + SURVEY_PAGE = 'Survey Page', + TECHNIQUES = 'Techniques', + SAMPLING_SITES = 'Sampling Sites', + SURVEY_METADATA = 'Survey Metadata', + OBSERVATIONS = 'Observations' +} diff --git a/app/src/interfaces/useObservationApi.interface.ts b/app/src/interfaces/useObservationApi.interface.ts index 0eb08ac81a..dc521ed39d 100644 --- a/app/src/interfaces/useObservationApi.interface.ts +++ b/app/src/interfaces/useObservationApi.interface.ts @@ -7,6 +7,7 @@ import { EnvironmentQuantitativeTypeDefinition } from 'interfaces/useReferenceApi.interface'; import { ApiPaginationResponseParams } from 'types/misc'; +import { IGetSampleLocationNonSpatialDetails } from './useSamplingSiteApi.interface'; export interface IGetSurveyObservationsResponse { surveyObservations: ObservationRecordWithSamplingAndSubcountData[]; supplementaryObservationData: SupplementaryObservationData; @@ -20,7 +21,7 @@ export interface IGetSurveyObservationsGeometryObject { export interface IGetSurveyObservationsGeometryResponse { surveyObservationsGeometry: IGetSurveyObservationsGeometryObject[]; - supplementaryObservationData: SupplementaryObservationData; + supplementaryObservationData: SupplementaryObservationCountData; } type ObservationSamplingData = { @@ -37,8 +38,8 @@ export type StandardObservationColumns = { survey_sample_method_id: number | null; survey_sample_period_id: number | null; count: number | null; - observation_date: string; - observation_time: string; + observation_date: string | null; + observation_time: string | null; latitude: number | null; longitude: number | null; }; @@ -46,6 +47,7 @@ export type StandardObservationColumns = { export type SubcountObservationColumns = { observation_subcount_id: number | null; observation_subcount_sign_id: number; + comment: string | null; subcount: number | null; qualitative_measurements: { field: string; @@ -65,6 +67,11 @@ export type SupplementaryObservationCountData = { observationCount: number; }; +export type ObservationSamplingSupplementaryData = { + // sample_sites: IGetBasicSampleLocation[]; + sample_sites: IGetSampleLocationNonSpatialDetails[]; +}; + export type SupplementaryObservationMeasurementData = { qualitative_measurements: CBQualitativeMeasurementTypeDefinition[]; quantitative_measurements: CBQuantitativeMeasurementTypeDefinition[]; @@ -72,7 +79,9 @@ export type SupplementaryObservationMeasurementData = { quantitative_environments: EnvironmentQuantitativeTypeDefinition[]; }; -export type SupplementaryObservationData = SupplementaryObservationCountData & SupplementaryObservationMeasurementData; +export type SupplementaryObservationData = SupplementaryObservationCountData & + SupplementaryObservationMeasurementData & + ObservationSamplingSupplementaryData; type ObservationSubCountQualitativeMeasurementRecord = { observation_subcount_id: number; @@ -144,6 +153,7 @@ type ObservationSubcountRecord = { observation_subcount_id: number; survey_observation_id: number; observation_subcount_sign_id: number; + comment: string; subcount: number | null; create_date: string; create_user: number; @@ -155,6 +165,7 @@ type ObservationSubcountRecord = { type ObservationSubcountObject = { observation_subcount_id: ObservationSubcountRecord['observation_subcount_id']; observation_subcount_sign_id: ObservationSubcountRecord['observation_subcount_sign_id']; + comment: ObservationSubcountRecord['comment']; subcount: ObservationSubcountRecord['subcount']; qualitative_measurements: ObservationSubcountQualitativeMeasurementObject[]; quantitative_measurements: ObservationSubcountQuantitativeMeasurementObject[]; diff --git a/app/src/interfaces/useSamplingSiteApi.interface.ts b/app/src/interfaces/useSamplingSiteApi.interface.ts index 33799d4129..5338a7951a 100644 --- a/app/src/interfaces/useSamplingSiteApi.interface.ts +++ b/app/src/interfaces/useSamplingSiteApi.interface.ts @@ -40,11 +40,22 @@ export interface IEditSampleSiteRequest { }; } -export interface IGetSampleSiteResponse { - sampleSites: IGetSampleLocationDetails[]; +export interface IGetSampleLocationNonSpatialResponse { + sampleSites: IGetSampleLocationNonSpatialDetails[]; pagination: ApiPaginationResponseParams; } +export interface IGetSampleLocationNonSpatialDetails { + survey_sample_site_id: number; + survey_id: number; + name: string; + description: string; + geometry_type: string; + sample_methods: IGetSampleMethodDetails[]; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; +} + export interface IGetSampleLocationRecord { survey_sample_site_id: number; survey_id: number; @@ -58,6 +69,42 @@ export interface IGetSampleLocationRecord { revision_count: number; } +export interface IGetSampleSiteGeometryResponse { + sampleSites: IGetSampleSiteGeometry[]; +} + +export interface IFindSampleSiteResponse { + sites: { + survey_sample_site_id: number; + survey_id: number; + name: string; + description: string | null; + geometry_type: string; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; + }[]; + pagination: ApiPaginationResponseParams; +} + +export interface IFindSampleSiteRecord { + survey_sample_site_id: number; + survey_id: number; + name: string; + description: string | null; + geometry_type: string; + blocks: IGetSampleBlockDetails[]; + stratums: IGetSampleStratumDetails[]; +} +export interface IFindSampleSiteResponse { + sites: IFindSampleSiteRecord[]; + pagination: ApiPaginationResponseParams; +} + +export interface IGetSampleSiteGeometry { + survey_sample_site_id: number; + geojson: Feature; +} + export interface IGetSampleLocationDetails { survey_sample_site_id: number; survey_id: number; @@ -69,6 +116,29 @@ export interface IGetSampleLocationDetails { stratums: IGetSampleStratumDetails[]; } +export interface IGetBasicSamplePeriod { + survey_sample_period_id: number; + survey_sample_method_id: number; + start_date: string; + end_date: string; + start_time: string; + end_time: string; +} + +export interface IGetBasicSampleMethod { + survey_sample_method_id: number; + survey_sample_site_id: number; + method_response_metric_id: number; + technique: { survey_technique_id: number; name: string }; + sample_periods: IGetBasicSamplePeriod[]; +} + +export interface IGetBasicSampleLocation { + survey_sample_site_id: number; + name: string; + sample_methods: IGetBasicSampleMethod; +} + export interface IGetSampleLocationDetailsForUpdate { survey_sample_site_id: number | null; survey_id: number; @@ -84,11 +154,11 @@ export interface IGetSampleBlockDetails { survey_sample_block_id: number; survey_sample_site_id: number | null; survey_block_id: number; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; + // create_date: string; + // create_user: number; + // update_date: string | null; + // update_user: number | null; + // revision_count: number; name: string; description: string; } @@ -97,11 +167,11 @@ export interface IGetSampleStratumDetails { survey_sample_stratum_id: number; survey_sample_site_id: number; survey_stratum_id: number; - create_date: string; - create_user: number; - update_date: string | null; - update_user: number | null; - revision_count: number; + // create_date: string; + // create_user: number; + // update_date: string | null; + // update_user: number | null; + // revision_count: number; name: string; description: string; } @@ -137,3 +207,28 @@ export interface IGetSamplePeriodRecord { update_user: number | null; revision_count: number; } + +export interface IFindSamplePeriodRecord { + survey_sample_period_id: number; + survey_sample_method_id: number; + survey_id: number; + start_date: string | null; + end_date: string | null; + start_time: string | null; + end_time: string | null; + sample_method: { + method_response_metric_id: number; + }; + method_technique: { + method_technique_id: number; + name: string; + }; + sample_site: { + survey_sample_site_id: number; + name: string; + }; +} +export interface IFindSamplePeriodResponse { + periods: IFindSamplePeriodRecord[]; + pagination: ApiPaginationResponseParams; +} diff --git a/app/src/interfaces/useSurveyApi.interface.ts b/app/src/interfaces/useSurveyApi.interface.ts index fd99d9a7fe..28c0958d07 100644 --- a/app/src/interfaces/useSurveyApi.interface.ts +++ b/app/src/interfaces/useSurveyApi.interface.ts @@ -70,6 +70,7 @@ export interface ISurveyBlockForm { survey_block_id: number | null; name: string; description: string; + geojson: Feature; sample_block_count: number; }[]; } @@ -135,9 +136,11 @@ export interface IGetSurveyLocation { export interface IGetSurveyBlock { survey_block_id: number; + survey_id?: number; name: string; description: string; revision_count: number; + geojson: Feature | null; sample_block_count: number; } @@ -219,6 +222,7 @@ export type IUpdateSurveyRequest = ISurveyLocationForm & { survey_block_id?: number | null; name: string; description: string; + geojson: Feature | null; }[]; site_selection: { strategies: string[]; @@ -441,6 +445,7 @@ export interface IGetSurveyForUpdateResponse { survey_id: number; name: string; description: string; + geojson: Feature; sample_block_count: number; revision_count: number; }[]; diff --git a/app/src/interfaces/useTechniqueApi.interface.ts b/app/src/interfaces/useTechniqueApi.interface.ts index 8d8832b9ba..4c2251bd32 100644 --- a/app/src/interfaces/useTechniqueApi.interface.ts +++ b/app/src/interfaces/useTechniqueApi.interface.ts @@ -4,13 +4,13 @@ export type TechniqueAttractant = { attractant_lookup_id: number; }; -type TechniqueQualitativeAttribute = { +export type TechniqueQualitativeAttribute = { method_technique_attribute_qualitative_id: number | null; method_lookup_attribute_qualitative_id: string; method_lookup_attribute_qualitative_option_id: string; }; -type TechniqueQuantitativeAttribute = { +export type TechniqueQuantitativeAttribute = { method_technique_attribute_quantitative_id: number | null; method_lookup_attribute_quantitative_id: string; value: number; @@ -47,6 +47,5 @@ export interface IGetTechniqueResponse { export interface IGetTechniquesResponse { techniques: IGetTechniqueResponse[]; - count: number; pagination: ApiPaginationResponseParams; } diff --git a/app/src/test-helpers/code-helpers.ts b/app/src/test-helpers/code-helpers.ts index 240f3e60ce..368f48a5da 100644 --- a/app/src/test-helpers/code-helpers.ts +++ b/app/src/test-helpers/code-helpers.ts @@ -66,5 +66,9 @@ export const codes: IGetAllCodeSetsResponse = { observation_subcount_signs: [ { id: 1, name: 'Scat', description: 'Scat left by the species.' }, { id: 2, name: 'Direct sighting', description: 'A direct sighting of the species.' } + ], + alert_types: [ + { id: 1, name: 'Survey', description: 'Alert about surveys.' }, + { id: 2, name: 'General', description: 'General alert.' } ] }; diff --git a/app/src/themes/appTheme.ts b/app/src/themes/appTheme.ts index cd82c81de1..6dc932c415 100644 --- a/app/src/themes/appTheme.ts +++ b/app/src/themes/appTheme.ts @@ -134,7 +134,6 @@ const appTheme = createTheme({ textOverflow: 'ellipsis' }, '& span': { - display: 'block', fontSize: '0.9rem', overflow: 'hidden', textOverflow: 'ellipsis' diff --git a/app/src/utils/datetime.test.ts b/app/src/utils/datetime.test.ts new file mode 100644 index 0000000000..c9257c9a10 --- /dev/null +++ b/app/src/utils/datetime.test.ts @@ -0,0 +1,53 @@ +import { combineDateTime, formatTimeDifference } from './datetime'; + +describe('combineDateTime', () => { + it('combines date and time into an ISO string', () => { + const result = combineDateTime('2024-01-01', '12:30:00'); + expect(result).toEqual('2024-01-01T12:30:00'); + }); + + it('combines date without time into an ISO string', () => { + const result = combineDateTime('2024-01-01'); + expect(result).toEqual('2024-01-01T00:00:00'); + }); + + it('returns ISO string for a different date and time', () => { + const result = combineDateTime('2023-12-31', '23:59:59'); + expect(result).toEqual('2023-12-31T23:59:59'); + }); + + it('handles invalid date formats gracefully', () => { + const date = combineDateTime('badDate', '12:00'); + expect(date).toEqual('Invalid Date'); + + const time = combineDateTime('2024-01-01', 'badtime'); + expect(time).toEqual('Invalid Date'); + }); +}); + +describe('formatTimeDifference', () => { + it('formats the time difference correctly between two dates and times', () => { + const result = formatTimeDifference('2024-01-01', '12:00', '2024-01-02', '13:30'); + expect(result).toEqual('1 day and 1 hour'); + }); + + it('handles time difference with only dates', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-03', null); + expect(result).toEqual('2 days'); + }); + + it('formats the time difference correctly with no time component', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-01', '01:00'); + expect(result).toEqual('1 hour'); + }); + + it('returns null when there is no time difference', () => { + const result = formatTimeDifference('2024-01-01', null, '2024-01-01', null); + expect(result).toBeNull(); + }); + + it('handles cases with invalid inputs', () => { + const result = formatTimeDifference('invalid-date', null, '2024-01-01', null); + expect(result).toBeNull(); + }); +}); diff --git a/app/src/utils/datetime.ts b/app/src/utils/datetime.ts index cec5233cf9..60eca541fa 100644 --- a/app/src/utils/datetime.ts +++ b/app/src/utils/datetime.ts @@ -1,3 +1,11 @@ +import dayjs from 'dayjs'; +import duration, { DurationUnitType } from 'dayjs/plugin/duration'; +import { pluralize } from './Utils'; + +const TIMESTAMP_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; + +dayjs.extend(duration); + /** * Combine date and time and return ISO string. * @@ -7,7 +15,51 @@ */ export const combineDateTime = (date: string, time?: string | null) => { if (date && time) { - return new Date(`${date}T${time}`).toISOString(); + return dayjs(`${date} ${time}`).format(TIMESTAMP_FORMAT); } - return new Date(`${date}T00:00:00`).toISOString(); + + return dayjs(`${date}`).format(TIMESTAMP_FORMAT); +}; + +/** + * Formats the time difference between two timestamps into a human-readable string. + * + * @param {string} startDate + * @param {(string | null)} startTime + * @param {string} endDate + * @param {(string | null)} endTime + * @returns {string | null} A formatted string indicating an amount of time + */ +export const formatTimeDifference = ( + startDate: string, + startTime: string | null, + endDate: string, + endTime: string | null +): string | null => { + const startDateTime = startTime ? dayjs(`${startDate} ${startTime}`) : dayjs(startDate); + const endDateTime = endTime ? dayjs(`${endDate} ${endTime}`) : dayjs(endDate); + + if (!startDateTime.isValid() || !endDateTime.isValid()) { + return null; + } + + // Calculate the total difference + const diff = dayjs.duration(endDateTime.diff(startDateTime)); + + const parts = []; + const units: DurationUnitType[] = ['year', 'month', 'day', 'hour', 'minute', 'second']; + + for (const unit of units) { + const value = diff.get(unit); + + if (value > 0) { + parts.push(`${value} ${pluralize(value, unit)}`); + } + } + + if (!parts.length) { + return null; + } + + return parts.slice(0, 2).join(' and '); }; diff --git a/app/src/utils/spatial-utils.test.ts b/app/src/utils/spatial-utils.test.ts index 7f4456b85b..161c1e7cb7 100644 --- a/app/src/utils/spatial-utils.test.ts +++ b/app/src/utils/spatial-utils.test.ts @@ -165,7 +165,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); }); @@ -183,7 +183,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.TRANSECT); }); @@ -198,7 +198,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); }); @@ -216,7 +216,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.POINT); }); @@ -238,7 +238,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); }); @@ -262,7 +262,7 @@ describe('getSamplingSiteSpatialType', () => { properties: {} }; - const response = getSamplingSiteSpatialType(feature); + const response = getSamplingSiteSpatialType(feature.geometry.type); expect(response).toEqual(SAMPLING_SITE_SPATIAL_TYPE.AREA); }); diff --git a/app/src/utils/spatial-utils.ts b/app/src/utils/spatial-utils.ts index 0b088e5cf5..13432db010 100644 --- a/app/src/utils/spatial-utils.ts +++ b/app/src/utils/spatial-utils.ts @@ -52,18 +52,16 @@ export const isGeoJsonPointFeature = (feature?: unknown): feature is Feature { - const geometryType = feature.geometry.type; - - if (['MultiLineString', 'LineString'].includes(geometryType)) { +export const getSamplingSiteSpatialType = (type: string): SAMPLING_SITE_SPATIAL_TYPE | null => { + if (['MultiLineString', 'LineString'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.TRANSECT; } - if (['Point', 'MultiPoint'].includes(geometryType)) { + if (['Point', 'MultiPoint'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.POINT; } - if (['Polygon', 'MultiPolygon'].includes(geometryType)) { + if (['Polygon', 'MultiPolygon'].includes(type)) { return SAMPLING_SITE_SPATIAL_TYPE.AREA; } diff --git a/compose.yml b/compose.yml index 8d1955a247..df5d29b2a9 100644 --- a/compose.yml +++ b/compose.yml @@ -50,12 +50,6 @@ services: - DB_PORT=5432 - DB_DATABASE=${DB_DATABASE} - DB_SCHEMA=${DB_SCHEMA} - # Seed - - PROJECT_SEEDER_USER_IDENTIFIER=${PROJECT_SEEDER_USER_IDENTIFIER} - - NUM_SEED_PROJECTS=${NUM_SEED_PROJECTS} - - NUM_SEED_SURVEYS_PER_PROJECT=${NUM_SEED_SURVEYS_PER_PROJECT} - - NUM_SEED_OBSERVATIONS_PER_SURVEY=${NUM_SEED_OBSERVATIONS_PER_SURVEY} - - NUM_SEED_SUBCOUNTS_PER_OBSERVATION=${NUM_SEED_SUBCOUNTS_PER_OBSERVATION} # Keycloak - KEYCLOAK_HOST=${KEYCLOAK_HOST} - KEYCLOAK_REALM=${KEYCLOAK_REALM} diff --git a/database/.pipeline/lib/db.setup.deploy.js b/database/.pipeline/lib/db.setup.deploy.js index 08c9aeb14f..1a7d6fcfc4 100644 --- a/database/.pipeline/lib/db.setup.deploy.js +++ b/database/.pipeline/lib/db.setup.deploy.js @@ -74,7 +74,7 @@ const dbSetupDeploy = async (settings) => { CPU_REQUEST: '50m', CPU_LIMIT: '1000m', MEMORY_REQUEST: '100Mi', - MEMORY_LIMIT: '1.5Gi' + MEMORY_LIMIT: '1.75Gi' } }) ); diff --git a/database/src/migrations/20240905000000_capture_mortality_attachment_tables.ts b/database/src/migrations/20240905000000_capture_mortality_attachment_tables.ts new file mode 100644 index 0000000000..6c28ba0ce0 --- /dev/null +++ b/database/src/migrations/20240905000000_capture_mortality_attachment_tables.ts @@ -0,0 +1,158 @@ +import { Knex } from 'knex'; + +/** + * Create 2 new tables: + * + * CRITTER CAPTURE ATTACHMENT + * - critter_capture_attachment + * + * CRITTER MORTALITY ATTACHMENT + * - critter_mortality_attachment + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + ---------------------------------------------------------------------------------------- + -- Create capture and mortality attachment tables + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + SET SEARCH_PATH=biohub, public; + + ---------------------------------------------------------------------------------------- + -- Create capture attachment table + ---------------------------------------------------------------------------------------- + + CREATE TABLE critter_capture_attachment ( + critter_capture_attachment_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + uuid uuid DEFAULT public.gen_random_uuid() NOT NULL, + critter_id integer NOT NULL, + critterbase_capture_id uuid NOT NULL, + file_type varchar(300) NOT NULL, + file_name varchar(300), + title varchar(300), + description varchar(250), + key varchar(1000) NOT NULL, + file_size integer, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + + CONSTRAINT critter_capture_attachment_pk PRIMARY KEY (critter_capture_attachment_id) + ); + + COMMENT ON TABLE critter_capture_attachment IS 'A list of critter capture files (ex: critter capture files like pdf or jpeg).'; + COMMENT ON COLUMN critter_capture_attachment.critter_capture_attachment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN critter_capture_attachment.uuid IS 'The universally unique identifier for the record.'; + COMMENT ON COLUMN critter_capture_attachment.critter_id IS 'Foreign key reference to the SIMS critter table.'; + COMMENT ON COLUMN critter_capture_attachment.critterbase_capture_id IS 'Critterbase capture identifier. External reference the Critterbase capture table.'; + COMMENT ON COLUMN critter_capture_attachment.file_type IS 'The attachment type. Attachment type examples include keyx, cfg, etc.'; + COMMENT ON COLUMN critter_capture_attachment.file_name IS 'The name of the file attachment.'; + COMMENT ON COLUMN critter_capture_attachment.title IS 'The title of the file.'; + COMMENT ON COLUMN critter_capture_attachment.description IS 'The description of the record.'; + COMMENT ON COLUMN critter_capture_attachment.key IS 'The identifying key to the file in the storage system.'; + COMMENT ON COLUMN critter_capture_attachment.file_size IS 'The size of the file in bytes.'; + COMMENT ON COLUMN critter_capture_attachment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN critter_capture_attachment.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN critter_capture_attachment.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN critter_capture_attachment.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN critter_capture_attachment.revision_count IS 'Revision count used for concurrency control.'; + + -- Add foreign key constraints + ALTER TABLE critter_capture_attachment + ADD CONSTRAINT critter_capture_attachment_fk1 + FOREIGN KEY (critter_id) + REFERENCES critter(critter_id); + + -- Add indexes for foreign keys + CREATE INDEX critter_capture_attachment_idx1 ON critter_capture_attachment(critter_id); + CREATE UNIQUE INDEX critter_capture_attachment_idx2 ON critter_capture_attachment(critter_id, critterbase_capture_id, file_name); + + ---------------------------------------------------------------------------------------- + -- Create audit and journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_critter_capture_attachment BEFORE INSERT OR UPDATE OR DELETE ON critter_capture_attachment FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_critter_capture_attachment AFTER INSERT OR UPDATE OR DELETE ON critter_capture_attachment FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + + + ---------------------------------------------------------------------------------------- + -- Create mortality attachment table + ---------------------------------------------------------------------------------------- + + CREATE TABLE critter_mortality_attachment ( + critter_mortality_attachment_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + uuid uuid DEFAULT public.gen_random_uuid() NOT NULL, + critter_id integer NOT NULL, + critterbase_mortality_id uuid NOT NULL, + file_type varchar(300) NOT NULL, + file_name varchar(300), + title varchar(300), + description varchar(250), + key varchar(1000) NOT NULL, + file_size integer, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + + CONSTRAINT critter_mortality_attachment_pk PRIMARY KEY (critter_mortality_attachment_id) + ); + + COMMENT ON TABLE critter_mortality_attachment IS 'A list of critter mortality files (ex: critter mortality files like pdf or jpeg).'; + COMMENT ON COLUMN critter_mortality_attachment.critter_mortality_attachment_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN critter_mortality_attachment.uuid IS 'The universally unique identifier for the record.'; + COMMENT ON COLUMN critter_mortality_attachment.critter_id IS 'Foreign key reference to the SIMS critter table.'; + COMMENT ON COLUMN critter_mortality_attachment.critterbase_mortality_id IS 'Critterbase mortality identifier. External reference to the Critterbase mortality table.'; + COMMENT ON COLUMN critter_mortality_attachment.file_type IS 'The attachment type. Attachment type examples include keyx, cfg, etc.'; + COMMENT ON COLUMN critter_mortality_attachment.file_name IS 'The name of the file attachment.'; + COMMENT ON COLUMN critter_mortality_attachment.title IS 'The title of the file.'; + COMMENT ON COLUMN critter_mortality_attachment.description IS 'The description of the record.'; + COMMENT ON COLUMN critter_mortality_attachment.key IS 'The identifying key to the file in the storage system.'; + COMMENT ON COLUMN critter_mortality_attachment.file_size IS 'The size of the file in bytes.'; + COMMENT ON COLUMN critter_mortality_attachment.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN critter_mortality_attachment.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN critter_mortality_attachment.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN critter_mortality_attachment.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN critter_mortality_attachment.revision_count IS 'Revision count used for concurrency control.'; + + -- Add foreign key constraints + ALTER TABLE critter_mortality_attachment + ADD CONSTRAINT critter_mortality_attachment_fk1 + FOREIGN KEY (critter_id) + REFERENCES critter(critter_id); + + -- Add indexes for foreign keys + CREATE INDEX critter_mortality_attachment_idx1 ON critter_mortality_attachment(critter_id); + CREATE UNIQUE INDEX critter_mortality_attachment_idx2 ON critter_mortality_attachment(critter_id, critterbase_mortality_id, file_name); + + ---------------------------------------------------------------------------------------- + -- Create audit and journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_critter_mortality_attachment BEFORE INSERT OR UPDATE OR DELETE ON critter_mortality_attachment FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_critter_mortality_attachment AFTER INSERT OR UPDATE OR DELETE ON critter_mortality_attachment FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW critter_capture_attachment as SELECT * FROM biohub.critter_capture_attachment; + CREATE OR REPLACE VIEW critter_mortality_attachment as SELECT * FROM biohub.critter_mortality_attachment; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240925121500_subcount_comment.ts b/database/src/migrations/20240925121500_subcount_comment.ts new file mode 100644 index 0000000000..155bd0c5f2 --- /dev/null +++ b/database/src/migrations/20240925121500_subcount_comment.ts @@ -0,0 +1,23 @@ +import { Knex } from 'knex'; + +/** + * Add comment column to observation subcounts + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH=biohub,biohub_dapi_v1; + + ALTER TABLE biohub.observation_subcount ADD COLUMN comment VARCHAR(1000); + COMMENT ON COLUMN observation_subcount.comment IS 'A comment or note about the subcount record.'; + + CREATE OR REPLACE VIEW biohub_dapi_v1.observation_subcount AS SELECT * FROM biohub.observation_subcount; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20240927000000_alert_table.ts b/database/src/migrations/20240927000000_alert_table.ts new file mode 100644 index 0000000000..a1b8e0ad8a --- /dev/null +++ b/database/src/migrations/20240927000000_alert_table.ts @@ -0,0 +1,130 @@ +import { Knex } from 'knex'; + +/** + * New table: + * - alert + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Create lookup table for alert type options + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub; + + CREATE TABLE alert_type ( + alert_type_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(100) NOT NULL, + description varchar(100) NOT NULL, + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT alert_type_pk PRIMARY KEY (alert_type_id) + ); + + COMMENT ON TABLE alert_type IS 'Alert type options that alerts can belong to.'; + COMMENT ON COLUMN alert_type.alert_type_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN alert_type.name IS 'The name of the alert_type.'; + COMMENT ON COLUMN alert_type.description IS 'The description of the alert_type and its intended use case.'; + COMMENT ON COLUMN alert_type.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN alert_type.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN alert_type.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN alert_type.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN alert_type.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN alert_type.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Create new table + ---------------------------------------------------------------------------------------- + + CREATE TYPE alert_severity AS ENUM ('info', 'warning', 'error', 'success'); + + CREATE TABLE alert ( + alert_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + alert_type_id integer NOT NULL, + name varchar(50) NOT NULL, + message varchar(250) NOT NULL, + data json, + severity alert_severity NOT NULL, + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT alert_pk PRIMARY KEY (alert_id) + ); + + COMMENT ON TABLE alert IS 'Alert records about various topics (i.e. bad data, system news, etc).'; + COMMENT ON COLUMN alert.alert_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN alert.alert_type_id IS 'The alert_type_id of the alert. Used to categorize/group alerts by type.'; + COMMENT ON COLUMN alert.name IS 'The name of the alert.'; + COMMENT ON COLUMN alert.message IS 'The message of the alert.'; + COMMENT ON COLUMN alert.severity IS 'The severity of the alert, used for MUI styling.'; + COMMENT ON COLUMN alert.data IS 'The data of the alert. Should ideally align with the type of the alert.'; + COMMENT ON COLUMN alert.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN alert.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN alert.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN alert.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN alert.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN alert.revision_count IS 'Revision count used for concurrency control.'; + + ---------------------------------------------------------------------------------------- + -- Create audit/journal triggers + ---------------------------------------------------------------------------------------- + + CREATE TRIGGER audit_alert BEFORE INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_alert AFTER INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + CREATE TRIGGER audit_alert_type BEFORE INSERT OR UPDATE OR DELETE ON biohub.alert_type FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_alert_type AFTER INSERT OR UPDATE OR DELETE ON biohub.alert FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create constraints/indexes on foreign keys + ---------------------------------------------------------------------------------------- + + ALTER TABLE alert ADD CONSTRAINT alert_fk1 + FOREIGN KEY (alert_type_id) + REFERENCES alert_type(alert_type_id); + + CREATE INDEX alert_idx1 ON alert(alert_type_id); + + ---------------------------------------------------------------------------------------- + -- Insert initial alert type options + ---------------------------------------------------------------------------------------- + + INSERT INTO + alert_type (name, description) + VALUES + ('Summary', 'General alerts for the summary page.'), + ('Manage Telemetry', 'Alerts about telemetry.'), + ('Manage Observations', 'Alerts about observations.'), + ('Manage Animals', 'Alerts about animals.'), + ('Manage Sampling', 'Alerts about sampling information.'), + ('Project', 'Alerts about Projects.'), + ('Survey', 'Alerts about Surveys.'), + ('Standards', 'Alerts about standards.'), + ('Funding', 'Alerts about funding sources.'), + ('Administrator', 'Alerts about administrator functions.'); + + ---------------------------------------------------------------------------------------- + -- Create views + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW alert AS SELECT * FROM biohub.alert; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241012021000_survey_jobs.ts b/database/src/migrations/20241012021000_survey_jobs.ts new file mode 100644 index 0000000000..43e3b11bb0 --- /dev/null +++ b/database/src/migrations/20241012021000_survey_jobs.ts @@ -0,0 +1,50 @@ +import { Knex } from 'knex'; + +/** + * add crew member and crew lead + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + + SET SEARCH_PATH=biohub; + + ---------------------------------------------------------------------------------------- + -- Adding survey participant roles + ---------------------------------------------------------------------------------------- + INSERT INTO survey_job (name, record_effective_date, description) + VALUES + ('Crew lead', NOW(), 'A participant of a survey in a crew lead role.'), + ('Crew member', NOW(), 'A participant of a survey in a crew member role.'); + + ---------------------------------------------------------------------------------------- + -- Reassigning sims biologist roles to crew member + ---------------------------------------------------------------------------------------- + + UPDATE survey_participation + SET survey_job_id = ( + SELECT survey_job_id + FROM survey_job + WHERE name = 'Crew member' + ) + WHERE survey_job_id = ( + SELECT survey_job_id + FROM survey_job + WHERE name = 'Biologist' + ); + + ---------------------------------------------------------------------------------------- + -- deleting sime biologist role + ---------------------------------------------------------------------------------------- + DELETE FROM survey_job + WHERE name = 'Biologist'; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241023115300_observation_contraints.ts b/database/src/migrations/20241023115300_observation_contraints.ts new file mode 100644 index 0000000000..394ac6dd23 --- /dev/null +++ b/database/src/migrations/20241023115300_observation_contraints.ts @@ -0,0 +1,34 @@ +import { Knex } from 'knex'; + +/** + * Drops NOT NULL constraints on observation latitude, longitude, date and time. + * Observations can be valid without locations and timestamps. eg. A surveyor only measured the start and end of their + * sampling period, not the time of each observation made during that period. + * + * When interpreting the data, observations without locations/timestamps are assumed to inherit the location/time range of + * associated sampling records. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(` + SET SEARCH_PATH=biohub; + + ALTER TABLE survey_observation + ALTER COLUMN latitude DROP NOT NULL, + ALTER COLUMN longitude DROP NOT NULL, + ALTER COLUMN observation_date DROP NOT NULL, + ALTER COLUMN observation_time DROP NOT NULL, + ADD CONSTRAINT survey_observation_date_check + CHECK (observation_date IS NOT NULL OR survey_sample_period_id IS NOT NULL), + ADD CONSTRAINT survey_observation_location_check + CHECK ((latitude IS NOT NULL AND longitude IS NOT NULL) OR survey_sample_period_id IS NOT NULL); + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241101160200_survey_block_geojson.ts b/database/src/migrations/20241101160200_survey_block_geojson.ts new file mode 100644 index 0000000000..1294e0b5c6 --- /dev/null +++ b/database/src/migrations/20241101160200_survey_block_geojson.ts @@ -0,0 +1,43 @@ +import { Knex } from 'knex'; + +/** + * Add geometry-related columns to the survey block table, allowing blocks to be spatial. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- Drop views + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + DROP VIEW IF EXISTS biohub_dapi_v1.survey_block; + + ---------------------------------------------------------------------------------------- + -- Alter tables/data + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub, public; + + ALTER TABLE biohub.survey_block ADD COLUMN geojson JSONB; + ALTER TABLE biohub.survey_block ADD COLUMN geometry geometry(geometry, 3005); + ALTER TABLE biohub.survey_block ADD COLUMN geography geography(geometry, 4326); + + COMMENT ON COLUMN survey_block.geojson IS 'A JSON representation of the project boundary geometry that provides necessary details for shape manipulation in client side tools.'; + COMMENT ON COLUMN survey_block.geometry IS 'The containing geometry of the record.'; + COMMENT ON COLUMN survey_block.geography IS 'The containing geography of the record.'; + + ---------------------------------------------------------------------------------------- + -- Update views + -------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW biohub_dapi_v1.survey_block AS SELECT * FROM biohub.survey_block; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241115120400_help_markdown.ts b/database/src/migrations/20241115120400_help_markdown.ts new file mode 100644 index 0000000000..340b141644 --- /dev/null +++ b/database/src/migrations/20241115120400_help_markdown.ts @@ -0,0 +1,169 @@ +import { Knex } from 'knex'; + +/** + * Add tables to store versioned text displayed in help dialogs. Versions can be up-scored or down-scored by users. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + SET SEARCH_PATH=biohub, public; + + ---------------------------------------------------------------------------------------- + -- Create markdown_type table + ---------------------------------------------------------------------------------------- + + CREATE TABLE markdown_type ( + markdown_type_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(25) NOT NULL, + description varchar(400) NOT NULL, + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT markdown_type_pk PRIMARY KEY (markdown_type_id) + ); + + COMMENT ON TABLE markdown_type IS 'Table to store types of markdown documents.'; + COMMENT ON COLUMN markdown_type.markdown_type_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN markdown_type.name IS 'Name of the markdown type.'; + COMMENT ON COLUMN markdown_type.description IS 'Description of the markdown type.'; + COMMENT ON COLUMN markdown_type.record_end_date IS 'Date when the record was marked as inactive.'; + COMMENT ON COLUMN markdown_type.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN markdown_type.create_user IS 'The id of the user who created the record.'; + COMMENT ON COLUMN markdown_type.update_date IS 'The datetime the record was last updated.'; + COMMENT ON COLUMN markdown_type.update_user IS 'The id of the user who last updated the record.'; + COMMENT ON COLUMN markdown_type.revision_count IS 'Revision count used for concurrency control.'; + + CREATE TRIGGER audit_markdown_type BEFORE INSERT OR UPDATE OR DELETE ON markdown_type FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_markdown_type AFTER INSERT OR UPDATE OR DELETE ON markdown_type FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create markdown table + ---------------------------------------------------------------------------------------- + + CREATE TABLE markdown ( + markdown_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + markdown_type_id integer NOT NULL, + data varchar NOT NULL, + score integer DEFAULT 0 NOT NULL, + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT markdown_pk PRIMARY KEY (markdown_id) + ); + + COMMENT ON TABLE markdown IS 'Table to store markdown records associated with markdown types.'; + COMMENT ON COLUMN markdown.markdown_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN markdown.markdown_type_id IS 'Foreign key reference to the markdown type.'; + COMMENT ON COLUMN markdown.data IS 'The content of the markdown document.'; + COMMENT ON COLUMN markdown.score IS 'Score or ranking associated with the markdown document.'; + COMMENT ON COLUMN markdown.record_end_date IS 'Date when the record was marked as inactive.'; + COMMENT ON COLUMN markdown.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN markdown.create_user IS 'The id of the user who created the record.'; + COMMENT ON COLUMN markdown.update_date IS 'The datetime the record was last updated.'; + COMMENT ON COLUMN markdown.update_user IS 'The id of the user who last updated the record.'; + COMMENT ON COLUMN markdown.revision_count IS 'Revision count used for concurrency control.'; + + ALTER TABLE markdown ADD CONSTRAINT markdown_fk1 FOREIGN KEY (markdown_type_id) REFERENCES markdown_type(markdown_type_id); + + CREATE INDEX markdown_idx1 ON markdown(markdown_type_id); + + -- Add unique end-date index + CREATE UNIQUE INDEX markdown_nuk1 ON markdown(markdown_type_id, (record_end_date is NULL)) where record_end_date is null; + + CREATE TRIGGER audit_markdown BEFORE INSERT OR UPDATE OR DELETE ON markdown FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_markdown AFTER INSERT OR UPDATE OR DELETE ON markdown FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Create table for tracking which users have scored on markdown records (only 1 score per user) + ---------------------------------------------------------------------------------------- + + CREATE TABLE markdown_user ( + markdown_user_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + system_user_id integer NOT NULL, + markdown_id integer NOT NULL, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT markdown_user_pk PRIMARY KEY (markdown_user_id) + ); + + COMMENT ON TABLE markdown_user IS 'Table to store markdown records associated with markdown types.'; + COMMENT ON COLUMN markdown_user.system_user_id IS 'The id of the user who scored on the markdown record.'; + COMMENT ON COLUMN markdown_user.markdown_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN markdown_user.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN markdown_user.create_user IS 'The id of the user who created the record.'; + COMMENT ON COLUMN markdown_user.update_date IS 'The datetime the record was last updated.'; + COMMENT ON COLUMN markdown_user.update_user IS 'The id of the user who last updated the record.'; + COMMENT ON COLUMN markdown_user.revision_count IS 'Revision count used for concurrency control.'; + + ALTER TABLE markdown_user ADD CONSTRAINT markdown_user_fk1 FOREIGN KEY (markdown_id) REFERENCES markdown(markdown_id); + ALTER TABLE markdown_user ADD CONSTRAINT markdown_user_fk2 FOREIGN KEY (system_user_id) REFERENCES system_user(system_user_id); + + CREATE INDEX markdown_user_idx1 ON markdown_user(system_user_id); + CREATE INDEX markdown_user_idx2 ON markdown_user(markdown_id); + + CREATE UNIQUE INDEX markdown_user_uk1 ON markdown_user(markdown_id, system_user_id); + + CREATE TRIGGER audit_markdown_user BEFORE INSERT OR UPDATE OR DELETE ON markdown_user FOR EACH ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_markdown_user AFTER INSERT OR UPDATE OR DELETE ON markdown_user FOR EACH ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- Insert markdown for dialogs + ---------------------------------------------------------------------------------------- + INSERT INTO + markdown_type (name, description) + VALUES + ('Projects and Surveys', 'Help text about Projects and Surveys'), + ('Summary Data', 'Help text about all the data that a user has access to'), + ('Sampling Information', 'Help text about sampling methods and information.'), + ('Survey Data', 'Help text about data collected during surveys.'), + ('Project Details', 'Help text about project details.'), + ('Surveys', 'Help text about surveys.'), + ('Survey Page', 'Help text about survey pages.'), + ('Techniques', 'Help text about techniques used for sampling.'), + ('Sampling Sites', 'Help text about sampling sites.'), + ('Survey Metadata', 'Help text about survey metadata.'), + ('Observations', 'Help text about the observations manage page.'); + + INSERT INTO + markdown (markdown_type_id, data) + VALUES + (1, '## Projects and Surveys\n\nProjects and Surveys let you organize and manage access to data.\n\n##### Projects\nProjects are folders containing information that is only accessible to the Project team.\n- All Projects you have created or been invited to will appear in your Projects list.\n- If you need access to an existing Project, your collaborator can invite you.\n\n##### Surveys\nSurveys let you organize ecological data collected in the field.\n- When adding new data, you can create a new Survey or add to an existing Survey.'), + (2, '## Data\n\nThis section lets you view all data that you have access to, combining data across Surveys.\n- When data is added to one of your Surveys, those data will also show up here.\n- If you are looking for specific data, you can filter the data using search criteria.'), + (3, '## Sampling Information\n\nThis section covers where, when, and how you collected data for the Survey.\n\n##### Sampling Techniques\nTechniques are the methods used to collect data.\n- When you create a technique, you’ll select a general method that the technique represents (e.g., camera trap).\n- You can add extra details about how you did that method, like the type of camera used.\n\n##### Sampling Sites\nSites are the precise locations where you collected data.\n- They can be points, lines, or areas.\n- Use locations that best represent where you actually went, not the larger area you’re studying.\n\n##### Sampling Periods\nPeriods describe when you did a technique at a sampling site.\n- They help explain your data: was the species not seen because it wasn’t there, or because sampling hadn’t started yet\\?\n- They provide valuable information about sampling effort, helping compare datasets.'), + (4, '## Survey Data\n\nThis section shows data collected during your Survey.\n\n##### Observations\nObservations are sightings or counts of species.\n- Observations can include a species, location, time, count, environmental variables, and species-specific attributes.\n- The [Standards Page](https://sims.nrs.gov.bc.ca/standards) shows fields that can be added to observations.\n\n##### Animals\nAnimals represent individuals that you captured or marked during your Survey.\n- After creating an animal, you can add capture and mortality events.\n- You can record each animal’s markings and measurements.\n\n##### Telemetry\nTelemetry data shows animal movements recorded by GPS devices.\n- To add telemetry data, you must first add the animals and then add deployments.'), + (5, '## Project Details\n\nThis section shows the objectives and members of the Project.\n\n##### Team Members\nThe role of each team member determines their permissions in the Project.\n\n###### Coordinators\n- Able to manage the Project itself.\n- Can invite new team members, add and edit data, and publish Surveys to BiodiversityHub BC.\n- A Project can have multiple Coordinators.\n\n###### Collaborators\n- Collaborators can add and edit data.\n- Able to create new Surveys.\n- Cannot invite team members or publish Surveys to BiodiversityHub BC.\n\n###### Observers\n- View-only access to information.'), + (6, '## Surveys\n\nThis section shows Surveys in the Project.\n- Surveys contain ecological data like species observations.\n- When you return from the field with new data, you can choose to create a new Survey or add to an existing Survey.'), + (7, '## Survey Page\n\nThis page shows the details of a specific Survey.\n\n##### Parts of a Survey\nSurveys involve sampling information, data, attachments, and metadata.\n- Sampling information describes precisely where, when, and how data were collected.\n- Survey data represents what was recorded while sampling (e.g., species observations).\n- Attachments provide supplementary information not captured in the data.\n- Metadata includes information entered when the Survey was created (e.g., start and end dates, objectives).\n\n##### Updating Surveys\n- Project Coordinators and Collaborators can edit all information in the Survey.\n\n##### Publishing\nCoordinators can publish the Survey to BiodiversityHub BC.\n- If information changes after publishing, a new version can be published.'), + (8, '## Techniques\n\nTechniques represent the sampling methods used to collect data at a sampling site.\n- They indicate how you intended to sample, ignoring site-specific adjustments you made.\n- You can record site-specific adjustments when applying the technique.\n- If you collected data in multiple ways, you can create multiple techniques.\n- After creating techniques, you can apply them to sampling sites.'), + (9, '## Sampling Sites\n\nSampling sites are the exact locations where you collected data.\n- Sites can be points, lines, or areas on the map.\n- You can represent transects or routes with lines.\n\n##### What is my Sampling Site\\?\nSampling sites should be the most precise location you have for where you collected data.\n- Precise locations help explain why data doesn’t exist in a certain area.\n- If there’s an area with no observations or sites, it’s safe to assume the area wasn’t sampled.\n\n##### Revisiting Sites from an Earlier Survey\nIf you’re collecting data at a site from a previous Survey, you’ll need to add a new site at the same location.\n- This allows you to make updates if the site was slightly different without affecting the original Survey.\n- Adding a new site at the same location keeps the data organized and easier to manage.'), + (10, '## Survey Metadata\n\nThis section includes metadata about the Survey.\n- You can update this information by editing the Survey.'), + (11, '## Observations\n\nObservations are sightings or counts of species.\n\n##### Configuring the Table\nYou can add columns to the observations table to match your data.\n- The [Standards Page](https://sims.nrs.gov.bc.ca/standards) shows possible columns and allowed values for each.\n\n##### Importing\n- You can import observations from a .csv file.\n- Your .csv columns should exactly match what you see in the observations table.\n- You can import data with extra columns without configuring the table; the table will be configured based on your imported data.\n- You can download an sample .csv to see a working example.\n\n##### Editing\n- You can start editing a row by double clicking it.\n- You can comment on an observation by clicking the comment icon in the last column.\n- You must click SAVE to save your changes.'); + + + ---------------------------------------------------------------------------------------- + -- Create view + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH = biohub_dapi_v1; + + CREATE VIEW markdown AS SELECT * FROM biohub.markdown; + CREATE VIEW markdown_type AS SELECT * FROM biohub.markdown_type; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241116000001_remove_duplicate_constraint.ts b/database/src/migrations/20241116000001_remove_duplicate_constraint.ts new file mode 100644 index 0000000000..7cad1ac3fd --- /dev/null +++ b/database/src/migrations/20241116000001_remove_duplicate_constraint.ts @@ -0,0 +1,29 @@ +import { Knex } from 'knex'; + +/** + * In the migration `20240722000000_method_technique` a duplicate constraint was created. + * + * This migration removes the duplicate constraint. + * + * Constraints: + * - method_technique_attribute_qualitative_fk4 + * - method_technique_attribute_qualitative_fk5 + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- DROP DUPLICATE CONSTRAINT + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub; + + ALTER TABLE method_technique_attribute_qualitative DROP CONSTRAINT IF EXISTS method_technique_attribute_qualitative_fk5; + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241116000002_update_technique_qualitative_tables.ts b/database/src/migrations/20241116000002_update_technique_qualitative_tables.ts new file mode 100644 index 0000000000..fdf117251a --- /dev/null +++ b/database/src/migrations/20241116000002_update_technique_qualitative_tables.ts @@ -0,0 +1,123 @@ +import { Knex } from 'knex'; + +/** + * UPDATES TO EXISTING CONCEPTS: + * + * - Adds a table for storing options for qualitative attributes of techniques, making the options reusable + * - (ie. avoid duplicate records for qualitative attributes with the same options) + * + * - eg. If camera trap and dip net both have a "material" attribute with "plastic" as an option, there should be one "plastic" record that gets reused. + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- DROP EXISTING VIEW + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + -- Drop view to allow name and description column to be deleted + DROP VIEW IF EXISTS method_lookup_attribute_qualitative_option; + + ---------------------------------------------------------------------------------------- + -- CREATE NEW TABLE + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub; + + CREATE TABLE technique_attribute_qualitative_option ( + technique_attribute_qualitative_option_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(50) NOT NULL, + description varchar(3000), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT technique_attribute_qualitative_option_pk PRIMARY KEY (technique_attribute_qualitative_option_id) + ); + + COMMENT ON TABLE technique_attribute_qualitative_option IS 'Options to be selected for a technique_attribute_qualitative record, representing values for categorical attributes.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.technique_attribute_qualitative_option_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.name IS 'The name of the record.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.description IS 'The description of the record.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN technique_attribute_qualitative_option.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint (don't allow 2 records with the same name and a NULL record_end_date) + CREATE UNIQUE INDEX technique_attribute_qualitative_option_nuk1 ON technique_attribute_qualitative_option(name, (record_end_date is NULL)) where record_end_date is null; + + -- Add audit/journal triggers + CREATE TRIGGER audit_technique_attribute_qualitative_option BEFORE INSERT OR UPDATE OR DELETE ON technique_attribute_qualitative_option for each ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_technique_attribute_qualitative_option AFTER INSERT OR UPDATE OR DELETE ON technique_attribute_qualitative_option for each ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- ADD COLUMN TO EXISTING TABLE + ---------------------------------------------------------------------------------------- + + CREATE INDEX method_lookup_attribute_qualitative_option_idx2 ON method_lookup_attribute_qualitative_option(method_lookup_attribute_qualitative_option_id); + + -- Alter the qualitative options table to include a reference to the new table + + -- Add new column + ALTER TABLE method_lookup_attribute_qualitative_option ADD COLUMN technique_attribute_qualitative_option_id INTEGER; + + COMMENT ON COLUMN method_lookup_attribute_qualitative_option.technique_attribute_qualitative_option_id IS 'Foreign key to a technique attribute option.'; + + -- Add foreign key constraint + ALTER TABLE method_lookup_attribute_qualitative_option ADD CONSTRAINT method_lookup_attribute_qualitative_option_fk2 + FOREIGN KEY (technique_attribute_qualitative_option_id) + REFERENCES technique_attribute_qualitative_option(technique_attribute_qualitative_option_id); + + -- add indexes for foreign keys + CREATE INDEX method_lookup_attribute_qualitative_option_idx3 ON method_lookup_attribute_qualitative_option(technique_attribute_qualitative_option_id); + + ---------------------------------------------------------------------------------------- + -- MIGRATE EXISTING DATA + ---------------------------------------------------------------------------------------- + + -- Populate new table from existing options + WITH w_insert AS ( + INSERT INTO technique_attribute_qualitative_option (name, description) + SELECT name, description + FROM ( + SELECT name, description, + ROW_NUMBER() OVER (PARTITION BY name ORDER BY description) AS rn + FROM method_lookup_attribute_qualitative_option mla + ) subquery + WHERE rn = 1 + RETURNING name, description, technique_attribute_qualitative_option_id + ) + UPDATE method_lookup_attribute_qualitative_option + SET technique_attribute_qualitative_option_id = w_insert.technique_attribute_qualitative_option_id + FROM w_insert + WHERE method_lookup_attribute_qualitative_option.name = w_insert.name; + + -- Add not null constraints and drop name and description, which are replaced with the foreign key reference to technique_attribute_qualitative_option + ALTER TABLE method_lookup_attribute_qualitative_option ALTER COLUMN technique_attribute_qualitative_option_id SET NOT NULL; + + ALTER TABLE method_lookup_attribute_qualitative_option DROP COLUMN name; + ALTER TABLE method_lookup_attribute_qualitative_option DROP COLUMN description; + + ---------------------------------------------------------------------------------------- + -- ADD/UPDATE VIEWS + ---------------------------------------------------------------------------------------- + + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW technique_attribute_qualitative_option AS SELECT * FROM biohub.technique_attribute_qualitative_option; + CREATE OR REPLACE VIEW method_lookup_attribute_qualitative_option AS SELECT * FROM biohub.method_lookup_attribute_qualitative_option; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/migrations/20241116000003_add_method_vantage_tables.ts b/database/src/migrations/20241116000003_add_method_vantage_tables.ts new file mode 100644 index 0000000000..fa21a84768 --- /dev/null +++ b/database/src/migrations/20241116000003_add_method_vantage_tables.ts @@ -0,0 +1,363 @@ +import { Knex } from 'knex'; + +/** + * NEW CONCEPT: Vantage + * + * - Adds tables for vantage and vantage modes + * - Adds a join table to assign vantage modes to method lookup options, setting which vantage modes can be used for a method lookup option + * - Adds a join table to assign vantage modes to techniques + * + * @export + * @param {Knex} knex + * @return {*} {Promise} + */ +export async function up(knex: Knex): Promise { + await knex.raw(`--sql + ---------------------------------------------------------------------------------------- + -- ADD NEW VANTAGE-RELATED TABLES + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub; + + CREATE TABLE vantage ( + vantage_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + name varchar(50) NOT NULL, + description varchar(1000), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT vantage_pk PRIMARY KEY (vantage_id) + ); + + COMMENT ON TABLE vantage IS 'Vantages that vantage_mode records belong to, like categories of modes.'; + COMMENT ON COLUMN vantage.vantage_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN vantage.name IS 'The name of the record.'; + COMMENT ON COLUMN vantage.description IS 'The description of the record.'; + COMMENT ON COLUMN vantage.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN vantage.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN vantage.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN vantage.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN vantage.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN vantage.revision_count IS 'Revision count used for concurrency control.'; + + -- Triggers, indexes + + -- Add unique end-date key constraint (don't allow 2 records with the same name and a NULL record_end_date) + CREATE UNIQUE INDEX vantage_nuk1 ON vantage(name, (record_end_date is NULL)) where record_end_date is null; + + -- Add audit/journal triggers + CREATE TRIGGER audit_vantage BEFORE INSERT OR UPDATE OR DELETE ON vantage for each ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_vantage AFTER INSERT OR UPDATE OR DELETE ON vantage for each ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ------- + + CREATE TABLE vantage_mode ( + vantage_mode_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + vantage_id integer NOT NULL, + name varchar(50) NOT NULL, + description varchar(1000), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT vantage_mode_pk PRIMARY KEY (vantage_mode_id) + ); + + COMMENT ON TABLE vantage_mode IS 'Vantage mode options that can be made available for method lookup options.'; + COMMENT ON COLUMN vantage_mode.vantage_mode_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN vantage_mode.vantage_id IS 'The vantage of the record.'; + COMMENT ON COLUMN vantage_mode.name IS 'The name of the record.'; + COMMENT ON COLUMN vantage_mode.description IS 'The description of the record.'; + COMMENT ON COLUMN vantage_mode.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN vantage_mode.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN vantage_mode.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN vantage_mode.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN vantage_mode.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN vantage_mode.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint (don't allow 2 records with the same name and a NULL record_end_date) + CREATE UNIQUE INDEX vantage_mode_nuk1 ON vantage_mode(vantage_id, name, (record_end_date is NULL)) where record_end_date is null; + + -- Add indexes for foreign keys + ALTER TABLE vantage_mode ADD CONSTRAINT vantage_mode_fk1 + FOREIGN KEY (vantage_id) + REFERENCES vantage(vantage_id); + + -- Add foreign key index + CREATE INDEX vantage_mode_idx1 ON vantage_mode(vantage_id); + + -- Add audit/journal triggers + CREATE TRIGGER audit_vantage_mode BEFORE INSERT OR UPDATE OR DELETE ON vantage_mode for each ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_vantage_mode AFTER INSERT OR UPDATE OR DELETE ON vantage_mode for each ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ------- + + CREATE TABLE vantage_mode_method ( + vantage_mode_method_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + vantage_mode_id integer NOT NULL, + method_lookup_id integer NOT NULL, + description varchar(1000), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT vantage_mode_method_pk PRIMARY KEY (vantage_mode_method_id) + ); + + COMMENT ON TABLE vantage_mode_method IS 'Join table indicating which vantage modes apply to which method lookup options.'; + COMMENT ON COLUMN vantage_mode_method.vantage_mode_method_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN vantage_mode_method.vantage_mode_id IS 'The vantage mode option of the record.'; + COMMENT ON COLUMN vantage_mode_method.method_lookup_id IS 'The method lookup option of the record.'; + COMMENT ON COLUMN vantage_mode_method.description IS 'The description of the record.'; + COMMENT ON COLUMN vantage_mode_method.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN vantage_mode_method.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN vantage_mode_method.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN vantage_mode_method.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN vantage_mode_method.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN vantage_mode_method.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique end-date key constraint (don't allow 2 records with the same vantage_mode_id, method_lookup_id and a NULL record_end_date) + CREATE UNIQUE INDEX vantage_mode_method_nuk1 ON vantage_mode_method (vantage_mode_id, method_lookup_id, (record_end_date is NULL)) where record_end_date is null; + + -- Add foreign key constraints + ALTER TABLE vantage_mode_method ADD CONSTRAINT vantage_mode_method_fk1 + FOREIGN KEY (method_lookup_id) + REFERENCES method_lookup (method_lookup_id); + + ALTER TABLE vantage_mode_method ADD CONSTRAINT vantage_mode_method_fk2 + FOREIGN KEY (vantage_mode_id) + REFERENCES vantage_mode (vantage_mode_id); + + -- Add indexes for foreign keys + CREATE INDEX vantage_mode_method_idx1 ON vantage_mode_method(method_lookup_id); + + CREATE INDEX vantage_mode_method_idx2 ON vantage_mode_method(vantage_mode_id); + + -- Add audit/journal triggers + CREATE TRIGGER audit_vantage_mode_method BEFORE INSERT OR UPDATE OR DELETE ON vantage_mode_method for each ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_vantage_mode_method AFTER INSERT OR UPDATE OR DELETE ON vantage_mode_method for each ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ------- + + CREATE TABLE method_technique_vantage_mode ( + method_technique_vantage_mode_id integer GENERATED ALWAYS AS IDENTITY (START WITH 1 INCREMENT BY 1), + method_technique_id integer NOT NULL, + vantage_mode_method_id integer NOT NULL, + description varchar(1000), + record_end_date date, + create_date timestamptz(6) DEFAULT now() NOT NULL, + create_user integer NOT NULL, + update_date timestamptz(6), + update_user integer, + revision_count integer DEFAULT 0 NOT NULL, + CONSTRAINT method_technique_vantage_mode_pk PRIMARY KEY (method_technique_vantage_mode_id) + ); + + COMMENT ON TABLE method_technique_vantage_mode IS 'Join table applying vantage modes to techniques.'; + COMMENT ON COLUMN method_technique_vantage_mode.method_technique_vantage_mode_id IS 'System generated surrogate primary key identifier.'; + COMMENT ON COLUMN method_technique_vantage_mode.method_technique_id IS 'The method technique of the record.'; + COMMENT ON COLUMN method_technique_vantage_mode.vantage_mode_method_id IS 'The vantage mode of the record.'; + COMMENT ON COLUMN method_technique_vantage_mode.description IS 'The description of the record.'; + COMMENT ON COLUMN method_technique_vantage_mode.record_end_date IS 'Record level end date.'; + COMMENT ON COLUMN method_technique_vantage_mode.create_date IS 'The datetime the record was created.'; + COMMENT ON COLUMN method_technique_vantage_mode.create_user IS 'The id of the user who created the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_vantage_mode.update_date IS 'The datetime the record was updated.'; + COMMENT ON COLUMN method_technique_vantage_mode.update_user IS 'The id of the user who updated the record as identified in the system user table.'; + COMMENT ON COLUMN method_technique_vantage_mode.revision_count IS 'Revision count used for concurrency control.'; + + -- Add unique index + CREATE UNIQUE INDEX method_technique_vantage_mode_uk1 ON method_technique_vantage_mode (method_technique_id, vantage_mode_method_id); + + -- Add foreign key constraints + ALTER TABLE method_technique_vantage_mode ADD CONSTRAINT method_technique_vantage_mode_fk1 + FOREIGN KEY (method_technique_id) + REFERENCES method_technique (method_technique_id); + + ALTER TABLE method_technique_vantage_mode ADD CONSTRAINT method_technique_vantage_mode_fk2 + FOREIGN KEY (vantage_mode_method_id) + REFERENCES vantage_mode_method (vantage_mode_method_id); + + -- Add indexes for foreign keys + CREATE INDEX method_technique_vantage_mode_idx1 ON method_technique_vantage_mode(method_technique_id); + + CREATE INDEX method_technique_vantage_mode_idx2 ON method_technique_vantage_mode(vantage_mode_method_id); + + -- Add audit/journal triggers + CREATE TRIGGER audit_method_technique_vantage_mode BEFORE INSERT OR UPDATE OR DELETE ON method_technique_vantage_mode for each ROW EXECUTE PROCEDURE tr_audit_trigger(); + CREATE TRIGGER journal_method_technique_vantage_mode AFTER INSERT OR UPDATE OR DELETE ON method_technique_vantage_mode for each ROW EXECUTE PROCEDURE tr_journal_trigger(); + + ---------------------------------------------------------------------------------------- + -- POPULATE INITIAL VATAGE TABLE VALUES + ---------------------------------------------------------------------------------------- + + INSERT INTO + vantage (name, description) + VALUES + ('air', 'View from an aircraft or drone.'), + ('arboreal', 'View from the tree canopy.'), + ('water', 'View from a body of water.'), + ('benthic', 'View from the bottom of a waterbody.'), + ('ground', 'View from the ground.'); + + INSERT INTO + vantage_mode (vantage_id, name, description) + VALUES + -- Air Vantage Modes + ((SELECT vantage_id FROM vantage WHERE name = 'air'), 'helicopter', 'View from a helicopter.'), + ((SELECT vantage_id FROM vantage WHERE name = 'air'), 'plane', 'View from a plane.'), + ((SELECT vantage_id FROM vantage WHERE name = 'air'), 'drone', 'View from a drone.'), + + -- Arboreal Vantage Modes + ((SELECT vantage_id FROM vantage WHERE name = 'arboreal'), 'stationary fixture', 'View from a stationary fixture in the tree canopy.'), + ((SELECT vantage_id FROM vantage WHERE name = 'arboreal'), 'climbing', 'View from climbing in the tree canopy.'), + + -- Water Vantage Modes + ((SELECT vantage_id FROM vantage WHERE name = 'water'), 'stationary fixture', 'At a fixed position in or under the water.'), + ((SELECT vantage_id FROM vantage WHERE name = 'water'), 'boat', 'View from a boat or canoe.'), + ((SELECT vantage_id FROM vantage WHERE name = 'water'), 'kayak or canoe', 'View from a kayak or canoe.'), + ((SELECT vantage_id FROM vantage WHERE name = 'water'), 'submersible', 'View from an underwater submersible.'), + + -- Ground Vantage Modes + ((SELECT vantage_id FROM vantage WHERE name = 'ground'), 'stationary fixture', 'At a fixed position on the ground.'), + ((SELECT vantage_id FROM vantage WHERE name = 'ground'), 'foot', 'On foot.'), + ((SELECT vantage_id FROM vantage WHERE name = 'ground'), 'on-road vehicle', 'In a truck, car, or similar on-road vehicle.'), + ((SELECT vantage_id FROM vantage WHERE name = 'ground'), 'off-road vehicle', 'On a quad, dirtbike, or similar all-terrain vehicle.'), + ((SELECT vantage_id FROM vantage WHERE name = 'ground'), 'horseback', 'On horseback.'), + ((SELECT vantage_id FROM vantage WHERE name = 'ground'), 'snowmobile', 'On a snowmobile.'), + ((SELECT vantage_id FROM vantage WHERE name = 'ground'), 'bike', 'On a bicycle.'), + + -- Benthic Vantage Modes + ((SELECT vantage_id FROM vantage WHERE name = 'benthic'), 'stationary fixture', 'At a fixed position on the bottom of a waterbody.'), + ((SELECT vantage_id FROM vantage WHERE name = 'benthic'), 'submersible', 'View from a submersible on the bottom of a waterbody.'); + + INSERT INTO + vantage_mode_method (method_lookup_id, vantage_mode_id) + VALUES + -- Visual Encounter Method + -- Air Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'helicopter' AND v.name = 'air')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'plane' AND v.name = 'air')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'drone' AND v.name = 'air')), + + -- Arboreal Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'stationary fixture' AND v.name = 'arboreal')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'climbing' AND v.name = 'arboreal')), + + -- Water Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'boat' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'kayak or canoe' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'submersible' AND v.name = 'water')), + + -- Ground Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'foot' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'on-road vehicle' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'off-road vehicle' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'horseback' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'snowmobile' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'visual encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'bike' AND v.name = 'ground')), + + -- Audio Encounter Method + -- Air Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'helicopter' AND v.name = 'air')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'plane' AND v.name = 'air')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'drone' AND v.name = 'air')), + + -- Arboreal Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'stationary fixture' AND v.name = 'arboreal')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'climbing' AND v.name = 'arboreal')), + + -- Water Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'boat' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'kayak or canoe' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'submersible' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'stationary fixture' AND v.name = 'water')), + + -- Ground Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'foot' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'on-road vehicle' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'off-road vehicle' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'horseback' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'snowmobile' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'audio encounter'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'bike' AND v.name = 'ground')), + + -- Radar Method + -- Air Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'helicopter' AND v.name = 'air')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'plane' AND v.name = 'air')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'drone' AND v.name = 'air')), + + -- Ground Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'foot' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'on-road vehicle' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'off-road vehicle' AND v.name = 'ground')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'stationary fixture' AND v.name = 'ground')), + + -- Water Vantage Modes + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'boat' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'kayak or canoe' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'submersible' AND v.name = 'water')), + ((SELECT method_lookup_id FROM method_lookup WHERE LOWER(name) = 'radar'), + (SELECT vantage_mode_id FROM vantage_mode vm JOIN vantage v ON v.vantage_id = vm.vantage_id WHERE vm.name = 'stationary fixture' AND v.name = 'water')); + + ---------------------------------------------------------------------------------------- + -- ADD/UPDATE VIEWS + ---------------------------------------------------------------------------------------- + SET SEARCH_PATH=biohub_dapi_v1; + + CREATE OR REPLACE VIEW vantage AS SELECT * FROM biohub.vantage; + CREATE OR REPLACE VIEW vantage_mode AS SELECT * FROM biohub.vantage_mode; + CREATE OR REPLACE VIEW vantage_mode_method AS SELECT * FROM biohub.vantage_mode_method; + CREATE OR REPLACE VIEW method_technique AS SELECT * FROM biohub.method_technique; + CREATE OR REPLACE VIEW method_technique_vantage_mode AS SELECT * FROM biohub.method_technique_vantage_mode; + + `); +} + +export async function down(knex: Knex): Promise { + await knex.raw(``); +} diff --git a/database/src/seeds/01_db_system_users.ts b/database/src/seeds/01_db_system_users.ts index a0c8c0b96a..be1729f084 100644 --- a/database/src/seeds/01_db_system_users.ts +++ b/database/src/seeds/01_db_system_users.ts @@ -78,6 +78,16 @@ const systemUsers: SystemUserSeed[] = [ given_name: 'Andrew', family_name: 'Thompson', email: 'andrew.thompson@gov.bc.ca' + }, + { + identifier: 'ameijer', + type: SYSTEM_IDENTITY_SOURCE.IDIR, + role_name: SYSTEM_USER_ROLE_NAME.SYSTEM_ADMINISTRATOR, + user_guid: '74231B32026141A7ACEC6BCC0284F038', + display_name: 'Meijer, Annika WLRS:EX', + given_name: 'Annika', + family_name: 'Meijer', + email: 'annika.meijer@gov.bc.ca' } ]; diff --git a/database/src/seeds/03_basic_project_survey_setup.ts b/database/src/seeds/03_basic_project_survey_setup.ts index 5c8db2958d..29af40b074 100644 --- a/database/src/seeds/03_basic_project_survey_setup.ts +++ b/database/src/seeds/03_basic_project_survey_setup.ts @@ -51,6 +51,11 @@ export async function seed(knex: Knex): Promise { await knex.raw(`${insertAccessRequest()}`); } + // Insert system alerts + for (let i = 0; i < 8; i++) { + await knex.raw(`${insertSystemAlert()}`); + } + // Check if at least 1 project already exists const checkProjectsResponse = await knex.raw(checkAnyProjectExists()); @@ -87,6 +92,7 @@ export async function seed(knex: Knex): Promise { ${insertMethodTechnique(surveyId)} ${insertSurveySamplingMethodData(surveyId)} ${insertSurveySamplePeriodData(surveyId)} + ${insertSurveyBlockData(surveyId)} `); // Insert regions into surveys @@ -172,7 +178,50 @@ const insertSurveyParticipationData = (surveyId: number) => ` su.user_identifier = '${PROJECT_SEEDER_USER_IDENTIFIER}' ), 1) ), - 1 + (SELECT survey_job_id FROM survey_job LIMIT 1) + ) + ; +`; + +const insertSurveyBlockData = (surveyId: number) => ` + INSERT into survey_block + ( survey_id, name, description, geojson ) + VALUES + ( + ${surveyId}, + '${faker.lorem.words(2)}', + '${faker.lorem.words(10)}', + '{ + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -121.904297, + 50.930738 + ], + [ + -121.904297, + 51.971346 + ], + [ + -120.19043, + 51.971346 + ], + [ + -120.19043, + 50.930738 + ], + [ + -121.904297, + 50.930738 + ] + ] + ] + }, + "properties": {} + }' ) ; `; @@ -770,3 +819,31 @@ const insertAccessRequest = () => ` $$${faker.lorem.sentences(2)}$$ ); `; + +/** + * SQL to insert a fake system alert + * + */ +const insertSystemAlert = () => ` + INSERT INTO alert + ( + alert_type_id, + name, + message, + data, + severity, + record_end_date, + create_user, + update_user + ) + VALUES ( + (SELECT alert_type_id FROM alert_type ORDER BY random() LIMIT 1), + $$${faker.lorem.words(3)}$$, + $$${faker.lorem.sentences(2)}$$, + NULL, + '${faker.helpers.arrayElement(['info', 'success', 'warning', 'error'])}', + (CASE WHEN random() < 0.5 THEN NULL ELSE (CURRENT_DATE - INTERVAL '30 days') END), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1), + (SELECT system_user_id FROM system_user ORDER BY random() LIMIT 1) + ); +`;